Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 8 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions agent/agent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
11 changes: 7 additions & 4 deletions agent/channels/eve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand All @@ -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<string, string | readonly string[]> = {
...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 } };
},
});
28 changes: 0 additions & 28 deletions agent/skills/create-vite-app.md

This file was deleted.

2 changes: 1 addition & 1 deletion agent/tools/edit_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 0 additions & 1 deletion agent/tools/write_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
});
Expand Down
3 changes: 3 additions & 0 deletions app/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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";

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 }));
Expand All @@ -23,6 +25,7 @@ export function HomePage() {
{ clientContext, message },
{
beforeSend: createSession({ message, sessionId }),
modelId: selectedModel,
},
);
openSession(sessionId);
Expand Down
25 changes: 22 additions & 3 deletions components/chat/composer.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -20,6 +21,12 @@ type TextInputProps = {

type SubmitButtonProps = Pick<ComposerProps, "disabled" | "isGenerating" | "onStop">;

function SecondaryControls({ disabled }: { readonly disabled: boolean }) {
const { status } = useChatVoiceInput();
if (status === "recording") return null;
return <ModelSelector disabled={disabled} />;
}

function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;

Expand Down Expand Up @@ -105,8 +112,20 @@ export function Composer({ disabled, isGenerating, onSend, onStop }: ComposerPro
>
<TextInput disabled={disabled} onValueChange={onValueChange} value={value} />
<div className="flex min-w-0 items-center justify-end gap-1 pt-1">
<ChatVoiceInput disabled={audioDisabled} onValueChange={onValueChange} value={value} />
<SubmitButton disabled={submitDisabled} isGenerating={isGenerating} onStop={onStop} />
<ChatVoiceInput.Provider
disabled={audioDisabled}
onValueChange={onValueChange}
value={value}
>
<ChatVoiceInput.Error />
<ChatVoiceInput.Waveform />
<ChatVoiceInput.Timer />
<SecondaryControls disabled={disabled} />
<div className="flex gap-1.5">
<ChatVoiceInput.Button />
<SubmitButton disabled={submitDisabled} isGenerating={isGenerating} onStop={onStop} />
</div>
</ChatVoiceInput.Provider>
</div>
</form>
);
Expand Down
6 changes: 1 addition & 5 deletions components/chat/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ThreadMessage messageId={messageId}>
<article aria-label="Assistant" className={className}>
<article aria-label="Assistant" className="group/message relative pt-3 pb-12">
<div>{children}</div>
{actions && <div className="absolute bottom-5 left-0">{actions}</div>}
</article>
Expand Down
51 changes: 51 additions & 0 deletions components/chat/model-selector.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Button
aria-label="Select model"
className="gap-1 px-2 font-normal"
disabled={disabled}
hidden={hidden}
popoverTarget={menuId}
style={getMenuAnchorStyle(menuId)}
variant="ghost"
>
<span>{model.label}</span>
<ChevronDown aria-hidden="true" className="ml-0.5 size-3.5 text-muted-foreground" />
</Button>
<MenuContent
className="w-36 [position-area:top_span-right] [position-try-fallbacks:flip-block]"
id={menuId}
side="top"
>
{MODEL_OPTIONS.map((option) => (
<MenuItem
className="text-sm"
key={option.value}
onClick={() => setSelectedModel(option.value)}
popoverTarget={menuId}
>
{option.label}
</MenuItem>
))}
</MenuContent>
</>
);
}
7 changes: 6 additions & 1 deletion components/session/tool-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,16 @@ function isSettled(part: EveDynamicToolPart): boolean {
);
}

function DeletedLines({ count }: { readonly count: number }) {
if (count === 0) return null;
return <span className="text-destructive">-{count}</span>;
}

function FileDiffStats({ diff }: { readonly diff: FileDiff }) {
const { additions, deletions } = useMemo(() => getFileDiffStats(diff.diff), [diff.diff]);
return (
<span className="flex shrink-0 gap-1 font-mono text-sm">
<span className="text-destructive">-{deletions}</span>
<DeletedLines count={deletions} />
<span className="text-success">+{additions}</span>
</span>
);
Expand Down
3 changes: 3 additions & 0 deletions components/session/use-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
}
Expand Down
6 changes: 6 additions & 0 deletions lib/composer-store.ts
Original file line number Diff line number Diff line change
@@ -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<ComposerStore>()((set) => ({
draft: "",
selectedModel: DEFAULT_MODEL_ID,
setDraft: (draft) => set({ draft }),
setSelectedModel: (selectedModel) => set({ selectedModel }),
}));
7 changes: 4 additions & 3 deletions lib/file-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
18 changes: 18 additions & 0 deletions lib/models.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading