Skip to content
Closed
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 137 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,146 @@
# Claude Agent Instructions
## Onlook Agents Guide

Anthropic models must read and follow `onlook/AGENTS.md` before making any change.
Actionable rules for repo agents—keep diffs minimal, safe, token‑efficient.

This is a mandatory precondition: do not proceed until you have read and internalized the rules in `onlook/AGENTS.md`.
### Purpose & Scope

Key enforcement (see AGENTS.md for full details):
- Audience: automated coding agents working within this repository.
- Goal: small, correct diffs aligned with the project’s architecture.
- Non-goals: editing generated artifacts, lockfiles, or `node_modules`.

- Package manager: Bun only. Use Bun for all installs and scripts.
- `bun install` (not `npm install`)
- `bun add <pkg>` (not `npm install <pkg>`)
- `bun run <script>` (not `npm run <script>`)
- `bunx <cmd>` (not `npx <cmd>`)
### Repo Map

- Monorepo managed by Bun workspaces (see root `package.json`).
- App: `apps/web/client` (Next.js App Router + TailwindCSS).
- API routes: `apps/web/client/src/server/api/routers/*`, aggregated in
`apps/web/client/src/server/api/root.ts`.
- Shared utilities: `packages/*` (e.g., `packages/utility`).

### Stack & Runtimes

- UI: Next.js App Router, TailwindCSS.
- API: tRPC + Zod (`apps/web/client/src/server/api/*`).
- Package manager: Bun only — use Bun for all installs and scripts; do not use
npm, yarn, or pnpm.

### Agent Priorities

- Correctness first: minimal scope and targeted edits.
- Respect client/server boundaries in App Router.
- Prefer local patterns and existing abstractions; avoid one-off frameworks.
- Do not modify build outputs, generated files, or lockfiles.
- Use Bun for all scripts; do not introduce npm/yarn.
- Avoid running the local dev server in automation contexts.
- Follow the project’s structure and client/server boundaries.
- Respect type safety and

### Next.js App Router

- Default to Server Components. Add `use client` when using events,
state/effects, browser APIs, or client-only libs.
- App structure: `apps/web/client/src/app/**` (`page.tsx`, `layout.tsx`,
`route.ts`).
- Client providers live behind a client boundary (e.g.,
`apps/web/client/src/trpc/react.tsx`).
- Example roots: `apps/web/client/src/app/layout.tsx` (RSC shell, providers
wired, scripts gated by env).
- Components using `mobx-react-lite`'s `observer` must be client components
(include `use client`).

### tRPC API

- Routers live in `apps/web/client/src/server/api/routers/**` and must be
exported from `apps/web/client/src/server/api/root.ts`.
- Use `publicProcedure`/`protectedProcedure` from
`apps/web/client/src/server/api/trpc.ts`; validate inputs with Zod.
- Serialization handled by SuperJSON; return plain objects/arrays.
- Client usage via `apps/web/client/src/trpc/react.tsx` (React Query + tRPC
links).

### Auth & Supabase

- Server-side client: `apps/web/client/src/utils/supabase/server.ts` (uses Next
headers/cookies). Use in server components, actions, and routes.
- Browser client: `apps/web/client/src/utils/supabase/client/index.ts` for
client components.
- Never pass server-only clients into client code.

### Env & Config

- Define/validate env vars in `apps/web/client/src/env.ts` via
`@t3-oss/env-nextjs`.
- Expose browser vars with `NEXT_PUBLIC_*` and declare in the `client` schema.
- Prefer `env` from `@/env`. In server-only helpers (e.g., base URL in
`src/trpc/helpers.ts`), read `process.env` only for deployment vars like
`VERCEL_URL`/`PORT`. Never use `process.env` in client code; in shared
modules, guard with `typeof window === 'undefined'`.
- Import `./src/env` in `apps/web/client/next.config.ts` to enforce validation.

### Imports & Paths

- Use path aliases: `@/*` and `~/*` map to `apps/web/client/src/*` (see
`apps/web/client/tsconfig.json`).
- Do not import server-only modules into client components. Limited exception:
editor modules that already use `path`; reuse only there. Never import
`process` in client code.
- Split code by environment if needed (server file vs client file).

### MobX + React Stores

- Create store instances with `useState(() => new Store())` for stability across
renders.
- Keep active store in `useRef`; clean up async with
`setTimeout(() => storeRef.current?.clear(), 0)` to avoid route-change races.
- Avoid `useMemo` for store instances; React may drop memoized values leading to
data loss.
- Avoid putting the store instance in effect deps if it loops; split concerns
(e.g., project vs branch).
- `observer` components are client-only. Place one client boundary at the
feature entry; child observers need not include `use client` (e.g.,
`apps/web/client/src/app/project/[id]/_components/main.tsx`).
- Example store: `apps/web/client/src/components/store/editor/engine.ts:1` (uses
`makeAutoObservable`).

### Styling & UI

- TailwindCSS-first styling; global styles are already imported in
`apps/web/client/src/app/layout.tsx`.
- Prefer existing UI components from `@onlook/ui` and local patterns.
- Preserve dark theme defaults via `ThemeProvider` usage in layout.

### Internationalization

- `next-intl` is configured; provider lives in
`apps/web/client/src/app/layout.tsx`.
- Strings live in `apps/web/client/messages/*`. Add/modify keys there; avoid
hardcoded user-facing text.
- Keep keys stable; prefer additions over breaking renames.

### Common Pitfalls

- Missing `use client` where needed (events/browser APIs) causes unbound events;
a single boundary at the feature root is sufficient.
- New tRPC routers not exported in `src/server/api/root.ts` (endpoints
unreachable).
- Env vars not typed/exposed in `src/env.ts` cause runtime/edge failures. Prefer
`env`; avoid new `process.env` reads in client code.
- Importing server-only code into client components (bundling/runtime errors).
Note: `path` is already used in specific client code-editor modules; avoid
expanding Node API usage beyond those areas.
- Bypassing i18n by hardcoding strings instead of using message files/hooks.
- Avoid `useMemo` to create MobX stores (risk of lost references); avoid
synchronous cleanup on route change (race conditions).

### Context Discipline (for Agents)

If any instruction here or in `onlook/AGENTS.md` conflicts with your defaults, prefer `onlook/AGENTS.md`.
- Search narrowly with ripgrep; open only files you need.
- Read small sections; avoid `node_modules`, `.next`, large assets.
- Propose minimal diffs aligned with existing conventions; avoid wide refactors.

When in doubt, stop and re‑read `onlook/AGENTS.md` before acting.
### Notes

- Unit tests can be run with `bun test`
- Run type checking with `bun run typecheck`
- Apply database updates to local dev with `bun run db:push`
- Refrain from running the dev server
- DO NOT run `db:gen`. This is reserved for the maintainer.
- DO NOT use any type unless necessary
2 changes: 2 additions & 0 deletions apps/web/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/marked": "^6.0.0",
"@uiw/codemirror-extensions-basic-setup": "^4.23.10",
"@uiw/react-codemirror": "^4.23.10",
"@vercel/otel": "^1.13.0",
Expand All @@ -80,6 +81,7 @@
"localforage": "^1.10.0",
"lru-cache": "^11.2.1",
"lucide-react": "^0.486.0",
"marked": "^16.3.0",
"mobx-react-lite": "^4.1.0",
"motion": "^12.6.3",
"next": ">=15.5.2",
Expand Down
28 changes: 14 additions & 14 deletions apps/web/client/public/onlook-preload-script.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/web/client/src/app/api/chat/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './message';
export * from './stream';
export * from './usage';
48 changes: 48 additions & 0 deletions apps/web/client/src/app/api/chat/helpers/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { fromDbMessage, messages, toDbMessage } from "@onlook/db";
import { db } from "@onlook/db/src/client";
import type { ChatMessage } from "@onlook/models";
import { and, eq, gt } from "drizzle-orm";
import debounce from "lodash/debounce";

const upsertMessage = async ({
id,
conversationId,
message,
}: {
id: string;
conversationId: string;
message: ChatMessage;
}) => {
const dbMessage = toDbMessage(message, conversationId);
return await db.transaction(async (tx) => {
// Remove messages newer than the updated message
await tx.delete(messages).where(and(
eq(messages.conversationId, conversationId),
gt(messages.createdAt, dbMessage?.createdAt ?? new Date()),
));
const [updatedMessage] = await tx
.insert(messages)
.values({
...dbMessage,
id,
})
.onConflictDoUpdate({
target: [messages.id],
set: {
...dbMessage,
id,
},
}).returning();
Comment on lines +28 to +34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid updating primary key on conflict

Setting id in the UPDATE clause is redundant and can cause unnecessary write churn.

 .onConflictDoUpdate({
   target: [messages.id],
   set: {
-    ...dbMessage,
-    id,
+    ...dbMessage,
   },
 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.onConflictDoUpdate({
target: [messages.id],
set: {
...dbMessage,
id,
},
}).returning();
.onConflictDoUpdate({
target: [messages.id],
set: {
...dbMessage,
},
}).returning();
🤖 Prompt for AI Agents
In apps/web/client/src/app/api/chat/helpers/message.ts around lines 28 to 34,
the ON CONFLICT DO UPDATE currently includes id in the SET clause which updates
the primary key (redundant and causes write churn); remove id from the update
payload by excluding it from the object passed to set (e.g., spread dbMessage
but omit id or build a new object of fields to update), keep the conflict target
as messages.id and return the updated row as before.

return updatedMessage;
});
Comment on lines +15 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return domain type for consistency

upsertMessage returns a raw DB row; loadChat returns ChatMessage. Standardize on ChatMessage.

-export const upsertMessage = async ({
+export const upsertMessage = async ({
@@
-}) => {
+}): Promise<ChatMessage> => {
@@
-        const [updatedMessage] = await tx
+        const [updatedMessage] = await tx
             .insert(messages)
@@
-            }).returning();
-        return updatedMessage;
+            }).returning();
+        return fromDbMessage(updatedMessage);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dbMessage = toDbMessage(message, conversationId);
return await db.transaction(async (tx) => {
// Remove messages newer than the updated message
await tx.delete(messages).where(and(
eq(messages.conversationId, conversationId),
gt(messages.createdAt, dbMessage?.createdAt ?? new Date()),
));
const [updatedMessage] = await tx
.insert(messages)
.values({
...dbMessage,
id,
})
.onConflictDoUpdate({
target: [messages.id],
set: {
...dbMessage,
id,
},
}).returning();
return updatedMessage;
});
export const upsertMessage = async ({
message,
conversationId,
id,
}): Promise<ChatMessage> => {
const dbMessage = toDbMessage(message, conversationId);
return await db.transaction(async (tx) => {
// Remove messages newer than the updated message
await tx.delete(messages).where(and(
eq(messages.conversationId, conversationId),
gt(messages.createdAt, dbMessage?.createdAt ?? new Date()),
));
const [updatedMessage] = await tx
.insert(messages)
.values({
...dbMessage,
id,
})
.onConflictDoUpdate({
target: [messages.id],
set: {
...dbMessage,
id,
},
}).returning();
return fromDbMessage(updatedMessage);
});
};
🤖 Prompt for AI Agents
In apps/web/client/src/app/api/chat/helpers/message.ts around lines 15 to 36,
the function currently returns a raw DB row; change it to return the domain
ChatMessage: after receiving updatedMessage from the transaction, map it to the
domain type (use the existing mapper function such as
fromDbMessage/toChatMessage—or add one if missing) and return that mapped
ChatMessage; update the function signature to Promise<ChatMessage>, add the
required import for the mapper and types, and ensure any callers are updated to
expect the domain type.

};

export const debouncedUpsertMessage = debounce(upsertMessage, 500);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using lodash's debounce with an async function may not behave as expected. Debounced functions don’t reliably propagate promise results, which could lead to race conditions or missed DB updates. Consider using an async‐aware debounce or ensuring proper flush (e.g. with { leading: false, trailing: true } and manual flush) if DB persistence is critical.


export const loadChat = async (chatId: string): Promise<ChatMessage[]> => {
const result = await db.query.messages.findMany({
where: eq(messages.conversationId, chatId),
orderBy: (messages, { asc }) => [asc(messages.createdAt)],
});
return result.map((message) => fromDbMessage(message));
};
8 changes: 2 additions & 6 deletions apps/web/client/src/app/api/chat/helpers/stream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolCall } from '@ai-sdk/provider-utils';
import { ASK_TOOL_SET, BUILD_TOOL_SET, getAskModeSystemPrompt, getCreatePageSystemPrompt, getSystemPrompt, initModel } from '@onlook/ai';
import { getAskModeSystemPrompt, getCreatePageSystemPrompt, getSystemPrompt, initModel } from '@onlook/ai';
import { ChatType, LLMProvider, OPENROUTER_MODELS, type ModelConfig } from '@onlook/models';
import { generateObject, NoSuchToolError, type ToolSet } from 'ai';

Expand All @@ -18,17 +18,13 @@ export async function getModelFromType(chatType: ChatType) {
default:
model = await initModel({
provider: LLMProvider.OPENROUTER,
model: OPENROUTER_MODELS.CLAUDE_4_SONNET,
model: OPENROUTER_MODELS.OPEN_AI_GPT_5,
});
break;
}
return model;
}

export async function getToolSetFromType(chatType: ChatType) {
return chatType === ChatType.ASK ? ASK_TOOL_SET : BUILD_TOOL_SET;
}

export async function getSystemPromptFromType(chatType: ChatType) {
let systemPrompt: string;

Expand Down
53 changes: 31 additions & 22 deletions apps/web/client/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { trackEvent } from '@/utils/analytics/server';
import { ChatType } from '@onlook/models';
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { getToolSetFromType } from '@onlook/ai';
import { ChatType, type ChatMessage } from '@onlook/models';
import { convertToModelMessages, stepCountIs, streamText } from 'ai';
import { type NextRequest } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { checkMessageLimit, decrementUsage, errorHandler, getModelFromType, getSupabaseUser, getSystemPromptFromType, getToolSetFromType, incrementUsage, repairToolCall } from './helpers';
import { checkMessageLimit, decrementUsage, errorHandler, getModelFromType, getSupabaseUser, getSystemPromptFromType, incrementUsage, loadChat, repairToolCall } from './helpers';
import { debouncedUpsertMessage } from './helpers/message';

const MAX_STEPS = 20;

Expand Down Expand Up @@ -52,13 +54,6 @@ export async function POST(req: NextRequest) {
}

export const streamResponse = async (req: NextRequest, userId: string) => {
const body = await req.json();
const { messages, chatType, conversationId, projectId } = body as {
messages: UIMessage[],
chatType: ChatType,
conversationId: string,
projectId: string,
};
// Updating the usage record and rate limit is done here to avoid
// abuse in the case where a single user sends many concurrent requests.
// If the call below fails, the user will not be penalized.
Expand All @@ -68,8 +63,17 @@ export const streamResponse = async (req: NextRequest, userId: string) => {
} | null = null;

try {
const lastUserMessage = messages.findLast((message: UIMessage) => message.role === 'user');
const traceId = lastUserMessage?.id ?? uuidv4();
const { message, chatType, conversationId, projectId, traceId }: {
message: ChatMessage,
chatType: ChatType,
conversationId: string,
projectId: string,
traceId: string,
} = await req.json()

// create or update last message in database
await debouncedUpsertMessage({ id: message.id, conversationId, message });
const messages = await loadChat(conversationId);

if (chatType === ChatType.EDIT) {
usageRecord = await incrementUsage(req, traceId);
Expand Down Expand Up @@ -99,7 +103,7 @@ export const streamResponse = async (req: NextRequest, userId: string) => {
projectId,
userId,
chatType: chatType,
tags: ['chat'],
tags: ['chat', chatType],
langfuseTraceId: traceId,
sessionId: conversationId,
},
Expand All @@ -123,16 +127,21 @@ export const streamResponse = async (req: NextRequest, userId: string) => {
return result.toUIMessageStreamResponse(
{
originalMessages: messages,
messageMetadata: ({
part
}) => {
if (part.type === 'finish-step') {
return {
finishReason: part.finishReason,
}
}
},
generateMessageId: () => uuidv4(),
messageMetadata: (_) => ({
createdAt: new Date(),
conversationId,
context: [],
checkpoints: [],
}),
onError: errorHandler,
onFinish: async (message) => {
await debouncedUpsertMessage({
id: message.responseMessage.id,
conversationId,
message: message.responseMessage,
});
},
}
);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export const OverlayChatInput = observer(({
const handleSubmit = async () => {
try {
editorEngine.state.rightPanelTab = EditorTabValue.CHAT;
await editorEngine.chat.addEditMessage(inputState.value);
sendMessageToChat(ChatType.EDIT);
const message = await editorEngine.chat.addEditMessage(inputState.value);
sendMessageToChat(message, ChatType.EDIT);
setInputState(DEFAULT_INPUT_STATE);
} catch (error) {
console.error('Error sending message', error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const ChatInput = observer(({
? await editorEngine.chat.addAskMessage(savedInput)
: await editorEngine.chat.addEditMessage(savedInput);

await sendMessageToChat(chatMode);
await sendMessageToChat(message, chatMode);
setInputValue('');
} catch (error) {
console.error('Error sending message', error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AssistantChatMessage } from '@onlook/models';
import type { ChatMessage } from '@onlook/models';
import { MessageContent } from './message-content';

export const AssistantMessage = ({ message }: { message: AssistantChatMessage }) => {
export const AssistantMessage = ({ message }: { message: ChatMessage }) => {
return (
<div className="px-4 py-2 text-small content-start flex flex-col text-wrap gap-2">
<MessageContent
Expand Down
Loading
Loading