diff --git a/.env.example b/.env.example
index f8839e4..fb136f9 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,5 @@
# Created by `convex dev`; used by the browser and the eve persistence hook.
VITE_CONVEX_URL=https://your-deployment.convex.cloud
-# Used by eve's default Vercel AI Gateway provider.
-AI_GATEWAY_API_KEY=your-ai-gateway-key
+# Used only to mint short-lived live transcription tokens.
+TRANSCRIPTION_AI_GATEWAY_API_KEY=your-ai-gateway-key
diff --git a/AGENTS.md b/AGENTS.md
index 598ae23..0731b9a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,6 +22,12 @@ of the product.
- Keep functions and components small, linear, and responsible for one thing. If a
unit must understand unrelated or partially defined data, fix the boundary or data
model.
+- Compose sibling capabilities in their nearest common parent. A component owns only
+ the behavior implied by its name; do not move unrelated actions into it to hide
+ coordination. An optional feature must be removable by deleting its import and
+ composition node without breaking sibling capabilities.
+- Express UI variants with focused components and early returns. Do not accumulate
+ JSX in mutable variables or turn one component into a dispatcher for unrelated UI.
- Keep one source of truth and derive the rest. Model state with one explicit status,
not overlapping booleans or synchronization effects.
- Keep logic above JSX. Avoid ternaries and boolean chains in markup. Use
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 6a3273d..9f97e10 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -174,6 +174,8 @@ 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.
- **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.
@@ -220,9 +222,12 @@ agent/ Eve agent and its server-side adapters
skills/ optional stack recipes
tools/ narrow additions to Eve's built-in tool set
convex/ schema, session operations, and checkpoint persistence
-lib/ lowest-level non-component modules and runtime/vendor adapters
+lib/ lowest-level reusable modules and extractable feature packages
+ chat-voice-input/
+ self-contained voice input package
components/
ui/ generic visual primitives
+ composer/ message input, optional controls, and submit composition
code/ Pierre-backed source and diff facades
session/ conversation, activity, navigation, and preview control
workspace/ file navigation, tree, queries, and panel
@@ -235,16 +240,23 @@ Layer rules:
- `agent/` imports low-level `lib/` contracts and generated Convex APIs, never UI.
- `convex/` imports only pure `lib/` modules.
-- `lib/` never imports feature components, `app/`, `agent/`, or `convex/`. A module
- may be cross-runtime or browser-only, but its dependencies must make that boundary
- obvious.
+- `lib/` never imports feature components, `app/`, `agent/`, or `convex/`. An
+ extractable feature may own components as long as its directory remains
+ self-contained. Modules may be cross-runtime, browser-only, or server-only, but
+ filenames and dependencies must make those boundaries obvious.
- External data is validated and normalized at its boundary. Internal consumers
receive one stable shape instead of repeating defensive parsing.
- Feature components may import `ui/`, `lib/`, generated Convex APIs, and sibling or
lower feature facades. `app/` wires them together; nothing imports from `app/`.
+- Parents compose sibling capabilities and own only the coordination between them.
+ Feature components own the behavior named by their boundary and never absorb
+ unrelated sibling actions. Removing an optional feature at its composition site
+ must leave unrelated workflows intact.
- Vendor renderers stay behind `components/code/` or the workspace feature boundary.
Consumers do not depend on Pierre directly.
-- There are no barrel files. Modules export only what a real consumer uses.
+- There are no application barrel files. An extractable package directory may expose
+ one public `index.ts` plus explicit runtime subpaths such as `server`; its internal
+ imports remain relative so the directory can move unchanged.
- Split on responsibility, not line count. Extract shared code on its second real
consumer, not in anticipation of one.
diff --git a/README.md b/README.md
index ba3f5fb..a35922c 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,8 @@ bunx vercel env pull .env.local
bun run dev
```
-Set `AI_GATEWAY_API_KEY` in `.env.local` only when Vercel OIDC is unavailable.
+Set `TRANSCRIPTION_AI_GATEWAY_API_KEY` in `.env.local` to enable voice input.
+Eve continues to use Vercel OIDC, independently from this key.
Open [http://localhost:5173](http://localhost:5173).
diff --git a/agent/channels/transcription.ts b/agent/channels/transcription.ts
new file mode 100644
index 0000000..7bd7ab4
--- /dev/null
+++ b/agent/channels/transcription.ts
@@ -0,0 +1,13 @@
+import { defineChannel, POST } from "eve/channels";
+
+import { createTranscriptionTokenResponse } from "@/lib/chat-voice-input/server";
+
+export default defineChannel({
+ routes: [
+ POST("/eve/v1/transcription", () =>
+ createTranscriptionTokenResponse({
+ apiKey: process.env.TRANSCRIPTION_AI_GATEWAY_API_KEY,
+ }),
+ ),
+ ],
+});
diff --git a/bun.lock b/bun.lock
index 1cf1bef..9c4f8da 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "eve-code",
"dependencies": {
+ "@ai-sdk/gateway": "^4.0.0",
"@convex-dev/react-query": "^0.1.0",
"@fontsource-variable/geist": "^5.2.9",
"@pierre/diffs": "^1.2.12",
@@ -14,6 +15,7 @@
"@tanstack/react-query": "^5.101.2",
"@vercel/oidc": "^3.8.0",
"@vercel/sandbox": "^2.3.0",
+ "ai": "^7.0.0",
"convex": "^1.42.2",
"diff": "^9.0.0",
"eve": "^0.24.6",
diff --git a/components/session/composer.tsx b/components/composer/composer.tsx
similarity index 50%
rename from components/session/composer.tsx
rename to components/composer/composer.tsx
index f1183eb..b771b93 100644
--- a/components/session/composer.tsx
+++ b/components/composer/composer.tsx
@@ -2,6 +2,7 @@ import { ArrowUp, Square } from "lucide-react";
import { type FormEvent, type KeyboardEvent, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
+import ChatVoiceInput from "@/lib/chat-voice-input";
import { useComposerStore } from "@/lib/composer-store";
type ComposerProps = {
@@ -11,14 +12,57 @@ type ComposerProps = {
readonly onStop?: () => void;
};
+function SubmitButton({
+ disabled,
+ isGenerating,
+ onStop,
+}: {
+ readonly disabled: boolean;
+ readonly isGenerating: boolean;
+ readonly onStop?: () => void;
+}) {
+ if (isGenerating) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function handleKeyDown(event: KeyboardEvent): void {
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
+
+ event.preventDefault();
+ event.currentTarget.form?.requestSubmit();
+}
+
export function Composer({
disabled = false,
isGenerating = false,
onSend,
onStop,
}: ComposerProps) {
- const draft = useComposerStore((state) => state.draft);
- const setDraft = useComposerStore((state) => state.setDraft);
+ const value = useComposerStore((state) => state.draft);
+ const onChange = useComposerStore((state) => state.setDraft);
const textareaRef = useRef(null);
useEffect(() => {
@@ -27,19 +71,14 @@ export function Composer({
function handleSubmit(event: FormEvent): void {
event.preventDefault();
- const message = draft.trim();
- if (!message) return;
-
- setDraft("");
+ const message = value.trim();
+ if (!message || disabled || isGenerating) return;
+ onChange("");
onSend(message);
}
- function handleKeyDown(event: KeyboardEvent): void {
- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
-
- event.preventDefault();
- event.currentTarget.form?.requestSubmit();
- }
+ const audioDisabled = disabled || isGenerating;
+ const submitDisabled = disabled || !value.trim();
return (