From 43c7a6ce1b74523edb19153d6acbdba86be58986 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 24 Jul 2026 21:25:08 +0300 Subject: [PATCH 1/2] Add model selection and file creation diffs --- ARCHITECTURE.md | 11 ++++-- agent/agent.ts | 15 ++++++-- agent/channels/eve.ts | 11 +++--- agent/skills/create-vite-app.md | 28 --------------- agent/tools/edit_file.ts | 2 +- agent/tools/write_file.ts | 1 - app/home-page.tsx | 3 ++ components/chat/composer.tsx | 25 ++++++++++++-- components/chat/model-selector.tsx | 51 ++++++++++++++++++++++++++++ components/session/tool-activity.tsx | 7 +++- components/session/use-session.ts | 3 ++ lib/composer-store.ts | 6 ++++ lib/file-diff.ts | 7 ++-- lib/models.ts | 18 ++++++++++ lib/session-runtime.ts | 12 ++++--- tests/file-edit.test.ts | 12 ++++--- tests/models.test.ts | 11 ++++++ tests/session-runtime.test.ts | 6 +++- 18 files changed, 173 insertions(+), 56 deletions(-) delete mode 100644 agent/skills/create-vite-app.md create mode 100644 components/chat/model-selector.tsx create mode 100644 lib/models.ts create mode 100644 tests/models.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 003fd56..b60a031 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -102,6 +102,10 @@ remain in Eve's events instead of becoming parallel Convex records. Token usage recorded for future product decisions but does not enforce a product quota; Eve's per-session safety limits still apply. +The selected model is transient browser state sent with each turn. The Eve channel +validates it into current request attributes, and the agent resolves it at turn scope +with the shared default as fallback. Convex does not persist model selection. + ## Coding harness Eve's built-ins are the base. The local additions are deliberately narrow: @@ -113,7 +117,7 @@ Eve's built-ins are the base. The local additions are deliberately narrow: - **`clone_repository`** validates a public GitHub repository, clones it into the current workspace, and returns its root entries in the repository activity. - **`write_file`** preserves Eve's create/overwrite contract and read-before-write - protection, adding a bounded diff for complete replacements. + protection, adding a bounded diff for new files and complete replacements. - **`edit_file`** applies batched, exact, unique, non-overlapping replacements to one snapshot and stores a context-limited unified diff. - **`start_dev`** starts the model-selected server command, exposes its port, verifies @@ -174,8 +178,9 @@ Here `sessionId` is Eve's durable session ID, not the app's public session ID. Its header shows the selected GitHub repository when the workspace has one. The read-only workspace contains breadcrumbs, a keyboard-accessible tree, and a highlighted source viewer. File tool activity can open the corresponding file. -- **Composer** composes text input, the self-contained Chat Voice Input package, and - submit as independent controls. Audio is never recorded or persisted. +- **Composer** composes text input, a turn-scoped model selector, the self-contained + Chat Voice Input package, and submit as independent controls. Audio is never + recorded or persisted. - **Activity** projects Eve events into reasoning, tool calls, live Bash output, file diffs, and elapsed time. - **Session management** includes responsive sidebar navigation, rename, and delete. diff --git a/agent/agent.ts b/agent/agent.ts index c749e3e..5cb8841 100644 --- a/agent/agent.ts +++ b/agent/agent.ts @@ -1,7 +1,18 @@ -import { defineAgent } from "eve"; +import { defineAgent, defineDynamic } from "eve"; + +import { DEFAULT_MODEL_ID, isModelId } from "@/lib/models"; export default defineAgent({ - model: "anthropic/claude-haiku-4.5", + model: defineDynamic({ + fallback: DEFAULT_MODEL_ID, + events: { + "turn.started": (_event, ctx) => { + const model = ctx.session.auth.current?.attributes.model; + if (!isModelId(model)) return null; + return model; + }, + }, + }), limits: { maxInputTokensPerSession: 2_000_000, maxOutputTokensPerSession: 100_000, diff --git a/agent/channels/eve.ts b/agent/channels/eve.ts index 9329dbe..6ed169e 100644 --- a/agent/channels/eve.ts +++ b/agent/channels/eve.ts @@ -2,6 +2,7 @@ import { ForbiddenError, localDev, none, vercelOidc } from "eve/channels/auth"; import { defaultEveAuth, eveChannel } from "eve/channels/eve"; import { isPublicId, SESSION_ID_ATTRIBUTE, SESSION_ID_HEADER } from "@/lib/identity"; +import { isModelId, MODEL_HEADER } from "@/lib/models"; export default eveChannel({ auth: [vercelOidc(), localDev(), none()], @@ -10,18 +11,20 @@ export default eveChannel({ if (!auth) return { auth }; const sessionId = ctx.eve.request.headers.get(SESSION_ID_HEADER); - if (sessionId === null) return { auth }; - if (!isPublicId(sessionId)) { + if (sessionId !== null && !isPublicId(sessionId)) { throw new ForbiddenError({ code: "invalid_session_id", message: "The session id header is invalid.", }); } - const attributes = { + const attributes: Record = { ...auth.attributes, - [SESSION_ID_ATTRIBUTE]: sessionId, }; + if (sessionId !== null) attributes[SESSION_ID_ATTRIBUTE] = sessionId; + const model = ctx.eve.request.headers.get(MODEL_HEADER); + if (isModelId(model)) attributes.model = model; + return { auth: { ...auth, attributes } }; }, }); diff --git a/agent/skills/create-vite-app.md b/agent/skills/create-vite-app.md deleted file mode 100644 index 72abcf2..0000000 --- a/agent/skills/create-vite-app.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Create or initialize a Vite app in an empty workspace and make it accessible through the sandbox preview. ---- - -Inspect `/workspace` first. If a project exists, never reinitialize or overwrite it. - -Honor the user's requested Vite template or framework. If none is specified, create a minimal -vanilla Vite 8 app. A scaffold command does not produce a sandbox-ready preview by itself. - -Before calling `start_dev`, inspect and update `vite.config.js` or `vite.config.ts` so its -`server` configuration includes: - -```js -server: { - host: "0.0.0.0", - allowedHosts: true, - hmr: { protocol: "wss", clientPort: 443 }, -}, -``` - -Without `host` the Vercel Sandbox route cannot reach Vite. Without `allowedHosts` Vite rejects -the public sandbox hostname. Do not rely on CLI or framework defaults. - -Use a private ESM `package.json` with pnpm and `dev`/`build` scripts. For the vanilla fallback, -create `index.html`, `src/main.ts`, and `src/style.css` with a minimal system-font starter. - -Run `pnpm install`, call `start_dev` with `pnpm dev` on port 5173, and fix any public preview -error before continuing. Verify with `pnpm build` before finishing. diff --git a/agent/tools/edit_file.ts b/agent/tools/edit_file.ts index 6228d5f..22df04f 100644 --- a/agent/tools/edit_file.ts +++ b/agent/tools/edit_file.ts @@ -11,7 +11,7 @@ const editSchema = z.object({ export default defineTool({ description: - "Replace one or more exact, unique, non-overlapping text ranges in an existing file. Every oldText is matched against the original file, so use one call for multiple changes to the same file.", + "Replace one or more exact, unique, non-overlapping text ranges in an existing file. Copy each oldText verbatim from the latest file read. Every oldText is matched against the same original snapshot. If any edit fails, no changes are applied; read the file again and retry the call.", inputSchema: z.object({ edits: z.array(editSchema).min(1).max(50), filePath: z.string().min(1).max(4_096), diff --git a/agent/tools/write_file.ts b/agent/tools/write_file.ts index 62d0401..8c288b2 100644 --- a/agent/tools/write_file.ts +++ b/agent/tools/write_file.ts @@ -22,7 +22,6 @@ export default defineTool({ const fileDiff = computeFileDiff(input.filePath, original, input.content); ctx.abortSignal.throwIfAborted(); const result = writeFileOutputSchema.parse(await writeFile.execute(input, ctx)); - if (!result.existed) return result; if (!fileDiff) return result; return { ...result, ...fileDiff }; }); diff --git a/app/home-page.tsx b/app/home-page.tsx index 9ec3e1a..dca5a0d 100644 --- a/app/home-page.tsx +++ b/app/home-page.tsx @@ -4,6 +4,7 @@ import { href, useNavigate } from "react-router"; import { AppHeader } from "@/components/app-header"; import { SessionStart } from "@/components/session/session-start"; import { api } from "@/convex/_generated/api"; +import { useComposerStore } from "@/lib/composer-store"; import type { GitRepository } from "@/lib/github"; import { createPublicId } from "@/lib/identity"; import { sendTurn } from "@/lib/session-runtime"; @@ -11,6 +12,7 @@ import { sendTurn } from "@/lib/session-runtime"; export function HomePage() { const createSession = useConvexMutation(api.sessions.create); const navigate = useNavigate(); + const selectedModel = useComposerStore((state) => state.selectedModel); function openSession(sessionId: string): void { void navigate(href("/s/:sessionId", { sessionId })); @@ -23,6 +25,7 @@ export function HomePage() { { clientContext, message }, { beforeSend: createSession({ message, sessionId }), + modelId: selectedModel, }, ); openSession(sessionId); diff --git a/components/chat/composer.tsx b/components/chat/composer.tsx index af3031f..7919d8e 100644 --- a/components/chat/composer.tsx +++ b/components/chat/composer.tsx @@ -1,8 +1,9 @@ import { ArrowUp, Square } from "lucide-react"; import { type KeyboardEvent, type SubmitEvent, useEffect, useRef } from "react"; +import { ModelSelector } from "@/components/chat/model-selector"; import { Button } from "@/components/ui/button"; -import ChatVoiceInput from "@/lib/chat-voice-input"; +import ChatVoiceInput, { useChatVoiceInput } from "@/lib/chat-voice-input"; import { useComposerStore } from "@/lib/composer-store"; type ComposerProps = { @@ -20,6 +21,12 @@ type TextInputProps = { type SubmitButtonProps = Pick; +function SecondaryControls({ disabled }: { readonly disabled: boolean }) { + const { status } = useChatVoiceInput(); + if (status === "recording") return null; + return ; +} + function handleKeyDown(event: KeyboardEvent): void { if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; @@ -105,8 +112,20 @@ export function Composer({ disabled, isGenerating, onSend, onStop }: ComposerPro >
- - + + + + + +
+ + +
+
); diff --git a/components/chat/model-selector.tsx b/components/chat/model-selector.tsx new file mode 100644 index 0000000..b9871a7 --- /dev/null +++ b/components/chat/model-selector.tsx @@ -0,0 +1,51 @@ +import { ChevronDown } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { getMenuAnchorStyle, MenuContent, MenuItem } from "@/components/ui/menu"; +import { useComposerStore } from "@/lib/composer-store"; +import { MODEL_OPTIONS } from "@/lib/models"; + +type ModelSelectorProps = { + readonly disabled: boolean; + readonly hidden?: boolean; +}; + +export function ModelSelector({ disabled, hidden }: ModelSelectorProps) { + const selectedModel = useComposerStore((state) => state.selectedModel); + const setSelectedModel = useComposerStore((state) => state.setSelectedModel); + const model = MODEL_OPTIONS.find((option) => option.value === selectedModel) ?? MODEL_OPTIONS[0]; + const menuId = "composer-model-menu"; + + return ( + <> + + + {MODEL_OPTIONS.map((option) => ( + setSelectedModel(option.value)} + popoverTarget={menuId} + > + {option.label} + + ))} + + + ); +} diff --git a/components/session/tool-activity.tsx b/components/session/tool-activity.tsx index b2f67bf..bea17cc 100644 --- a/components/session/tool-activity.tsx +++ b/components/session/tool-activity.tsx @@ -103,11 +103,16 @@ function isSettled(part: EveDynamicToolPart): boolean { ); } +function DeletedLines({ count }: { readonly count: number }) { + if (count === 0) return null; + return -{count}; +} + function FileDiffStats({ diff }: { readonly diff: FileDiff }) { const { additions, deletions } = useMemo(() => getFileDiffStats(diff.diff), [diff.diff]); return ( - -{deletions} + +{additions} ); diff --git a/components/session/use-session.ts b/components/session/use-session.ts index d55ed8f..aa65103 100644 --- a/components/session/use-session.ts +++ b/components/session/use-session.ts @@ -4,6 +4,7 @@ import type { EveMessage, EveMessagePart, SendTurnPayload, SessionState } from " import { useEffect, useMemo } from "react"; import { api } from "@/convex/_generated/api"; +import { useComposerStore } from "@/lib/composer-store"; import { projectActivityTimings, projectEveMessages, type StoredEveEvent } from "@/lib/eve-events"; import { findPendingInput, isSessionLimitRequest } from "@/lib/pending-input"; import { @@ -112,6 +113,7 @@ export function isSessionGenerating( } export function useSession({ checkpointEvents, session, sessionId }: UseSessionOptions) { + const selectedModel = useComposerStore((state) => state.selectedModel); const connectionCount = useConvexConnectionState().connectionCount; const status = session?.status; const eveSessionId = session?.eveSessionId; @@ -168,6 +170,7 @@ export function useSession({ checkpointEvents, session, sessionId }: UseSessionO ? () => recordInputResponses({ inputResponses, sessionId, streamIndex: cursor }) : undefined, beforeSend: prepareTurn({ sessionId, streamIndex: cursor }), + modelId: selectedModel, sessionState: toSessionState(session), }); } diff --git a/lib/composer-store.ts b/lib/composer-store.ts index a168ce0..2836607 100644 --- a/lib/composer-store.ts +++ b/lib/composer-store.ts @@ -1,11 +1,17 @@ import { create } from "zustand"; +import { DEFAULT_MODEL_ID, type ModelId } from "@/lib/models"; + type ComposerStore = { readonly draft: string; + readonly selectedModel: ModelId; readonly setDraft: (value: string) => void; + readonly setSelectedModel: (model: ModelId) => void; }; export const useComposerStore = create()((set) => ({ draft: "", + selectedModel: DEFAULT_MODEL_ID, setDraft: (draft) => set({ draft }), + setSelectedModel: (selectedModel) => set({ selectedModel }), })); diff --git a/lib/file-diff.ts b/lib/file-diff.ts index 7de7c59..b0e4fbd 100644 --- a/lib/file-diff.ts +++ b/lib/file-diff.ts @@ -13,14 +13,15 @@ export function computeFileDiff( original: string | null, edited: string, ): FileDiff | undefined { - if (original === null || original === edited) return; + const previous = original ?? ""; + if (previous === edited) return; if ( - Buffer.byteLength(original, "utf8") > fileBytesMax || + Buffer.byteLength(previous, "utf8") > fileBytesMax || Buffer.byteLength(edited, "utf8") > fileBytesMax ) { return; } - const diff = createPatch(path, original, edited, undefined, undefined, { + const diff = createPatch(path, previous, edited, undefined, undefined, { context: 4, timeout: 2_000, }); diff --git a/lib/models.ts b/lib/models.ts new file mode 100644 index 0000000..8127ce8 --- /dev/null +++ b/lib/models.ts @@ -0,0 +1,18 @@ +export const MODEL_OPTIONS = [ + { label: "GPT 5.6 Terra", value: "openai/gpt-5.6-terra" }, + { label: "GPT 5.6 Luna", value: "openai/gpt-5.6-luna" }, + { label: "Claude Sonnet 5", value: "anthropic/claude-sonnet-5" }, + { label: "Claude Opus 5", value: "anthropic/claude-opus-5" }, + { label: "Gemini 3.6 Flash", value: "google/gemini-3.6-flash" }, + { label: "Deepseek V4 Flash", value: "deepseek/deepseek-v4-flash" }, + { label: "Kimi 2.7", value: "moonshotai/kimi-k2.7-code" }, +] as const; + +export type ModelId = (typeof MODEL_OPTIONS)[number]["value"]; + +export const DEFAULT_MODEL_ID: ModelId = MODEL_OPTIONS[0].value; +export const MODEL_HEADER = "x-eve-model"; + +export function isModelId(value: unknown): value is ModelId { + return MODEL_OPTIONS.some((model) => model.value === value); +} diff --git a/lib/session-runtime.ts b/lib/session-runtime.ts index 8a39a37..83f0eb1 100644 --- a/lib/session-runtime.ts +++ b/lib/session-runtime.ts @@ -10,6 +10,7 @@ import { create } from "zustand"; import type { OptimisticTurn, StoredEveEvent } from "@/lib/eve-events"; import { SESSION_ID_HEADER } from "@/lib/identity"; +import { DEFAULT_MODEL_ID, MODEL_HEADER, type ModelId } from "@/lib/models"; type Connection = { readonly controller: AbortController; @@ -35,6 +36,7 @@ type RuntimeStore = { type SendTurnOptions = { readonly afterSend?: () => Promise; readonly beforeSend?: Promise; + readonly modelId?: ModelId; readonly sessionState?: SessionState; }; @@ -171,6 +173,7 @@ async function runTurn( sessionId: string, connection: Connection, input: SendTurnPayload, + modelId: ModelId, afterSend?: () => Promise, beforeSend?: Promise, ): Promise { @@ -184,9 +187,8 @@ async function runTurn( } try { - const headers = connection.session.state.sessionId - ? input.headers - : { ...input.headers, [SESSION_ID_HEADER]: sessionId }; + const headers: Record = { ...input.headers, [MODEL_HEADER]: modelId }; + if (!connection.session.state.sessionId) headers[SESSION_ID_HEADER] = sessionId; const stream = await connection.session.send({ ...input, headers, @@ -214,7 +216,7 @@ async function runTurn( export function sendTurn( sessionId: string, input: SendTurnPayload, - { afterSend, beforeSend, sessionState }: SendTurnOptions = {}, + { afterSend, beforeSend, modelId = DEFAULT_MODEL_ID, sessionState }: SendTurnOptions = {}, ): void { const current = getSessionRuntime(sessionId); if (current && current.connection.status !== "failed") return; @@ -226,7 +228,7 @@ export function sendTurn( events: current?.events ?? [], optimistic: optimisticTurn(input, startIndex), }); - void runTurn(sessionId, connection, input, afterSend, beforeSend); + void runTurn(sessionId, connection, input, modelId, afterSend, beforeSend); } export function followSession(sessionId: string, state: SessionState): void { diff --git a/tests/file-edit.test.ts b/tests/file-edit.test.ts index e7b2345..9614e23 100644 --- a/tests/file-edit.test.ts +++ b/tests/file-edit.test.ts @@ -62,7 +62,8 @@ describe("file edits", () => { expect(diff.split("\n").filter((line) => line.startsWith("@@"))).toHaveLength(2); expect(getFileDiffStats(diff)).toEqual({ additions: 2, deletions: 2 }); expect(parsePatch(diff)[0]?.newFileName).toBe("src/style.css"); - expect(computeFileDiff("new.ts", null, edited)).toBeUndefined(); + const created = computeFileDiff("new.ts", null, "first\nsecond\n")?.diff ?? ""; + expect(getFileDiffStats(created)).toEqual({ additions: 2, deletions: 0 }); expect(computeFileDiff("same.ts", edited, edited)).toBeUndefined(); }); @@ -108,8 +109,11 @@ describe("file edits", () => { expect(content).toBe("color: green;\n"); content = null; - await expect( - writeFile.execute({ content: "new", filePath: "/workspace/new.ts" }, ctx), - ).resolves.toEqual({ existed: false, path: "/workspace/new.ts" }); + const created = await writeFile.execute( + { content: "first\nsecond\n", filePath: "/workspace/new.ts" }, + ctx, + ); + expect(created).toMatchObject({ existed: false, path: "/workspace/new.ts" }); + expect(getFileDiffStats(created.diff ?? "")).toEqual({ additions: 2, deletions: 0 }); }); }); diff --git a/tests/models.test.ts b/tests/models.test.ts new file mode 100644 index 0000000..cf676f0 --- /dev/null +++ b/tests/models.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_MODEL_ID, isModelId, MODEL_OPTIONS } from "@/lib/models"; + +describe("models", () => { + it("keeps the default in the supported options", () => { + expect(isModelId(DEFAULT_MODEL_ID)).toBe(true); + expect(MODEL_OPTIONS.map((model) => model.value)).toContain(DEFAULT_MODEL_ID); + expect(isModelId("unsupported/model")).toBe(false); + }); +}); diff --git a/tests/session-runtime.test.ts b/tests/session-runtime.test.ts index ca18d80..fd0e95a 100644 --- a/tests/session-runtime.test.ts +++ b/tests/session-runtime.test.ts @@ -39,6 +39,7 @@ import { type StoredSession, } from "@/components/session/use-session"; import { createPublicId, SESSION_ID_HEADER } from "@/lib/identity"; +import { DEFAULT_MODEL_ID, MODEL_HEADER, MODEL_OPTIONS } from "@/lib/models"; import { clearSessionRuntime, followSession, @@ -181,6 +182,7 @@ it("shows the optimistic message, then stops locally and cancels Eve", async () sendTurn(sessionId, { message: "Keep working" }); expect(sent.input?.headers?.[SESSION_ID_HEADER]).toBe(sessionId); + expect(sent.input?.headers?.[MODEL_HEADER]).toBe(DEFAULT_MODEL_ID); await vi.waitFor(() => expect(getSessionRuntime(sessionId)?.connection.turnId).toBe("turn-1")); const stopping = stopSession(sessionId); @@ -202,9 +204,11 @@ it("omits the app session header when Eve already has a session", async () => { }), ); - sendTurn(sessionId, { message: "Continue" }); + const selectedModel = MODEL_OPTIONS[1].value; + sendTurn(sessionId, { message: "Continue" }, { modelId: selectedModel }); await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()); expect(send.mock.calls[0]?.[0].headers?.[SESSION_ID_HEADER]).toBeUndefined(); + expect(send.mock.calls[0]?.[0].headers?.[MODEL_HEADER]).toBe(selectedModel); }); it("keeps a failed cancellation recoverable", async () => { From a33bd624a595f6b24fa16bcdf97fcdfbff817318 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 24 Jul 2026 21:26:36 +0300 Subject: [PATCH 2/2] Refactor AssistantMessage component to simplify className assignment --- components/chat/message.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/components/chat/message.tsx b/components/chat/message.tsx index fbd335d..e2bff10 100644 --- a/components/chat/message.tsx +++ b/components/chat/message.tsx @@ -80,13 +80,9 @@ export function UserMessage({ actions, children, messageId }: MessageProps) { } export function AssistantMessage({ actions, children, messageId }: MessageProps) { - const className = actions - ? "group/message relative pt-3 pb-12" - : "group/message relative pt-3 pb-5"; - return ( -
+
{children}
{actions &&
{actions}
}