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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 17 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
13 changes: 13 additions & 0 deletions agent/channels/transcription.ts
Original file line number Diff line number Diff line change
@@ -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,
}),
),
],
});
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 (
<Button
aria-label="Stop generating"
className="size-8 rounded-full"
disabled={!onStop}
onClick={onStop}
size="icon-sm"
>
<Square aria-hidden="true" className="fill-current" />
</Button>
);
}

return (
<Button
aria-label="Send message"
className="size-8 rounded-full"
disabled={disabled}
size="icon-sm"
type="submit"
>
<ArrowUp aria-hidden="true" className="size-[18px]" />
</Button>
);
}

function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>): 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<HTMLTextAreaElement>(null);

useEffect(() => {
Expand All @@ -27,19 +71,14 @@ export function Composer({

function handleSubmit(event: FormEvent<HTMLFormElement>): 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<HTMLTextAreaElement>): 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 (
<div className="shrink-0 px-4 pb-[max(1.5rem,env(safe-area-inset-bottom))] sm:px-6">
Expand All @@ -55,35 +94,16 @@ export function Composer({
className="max-h-48 min-h-16 w-full resize-none overflow-y-auto bg-transparent px-2 py-1 outline-none [field-sizing:content] placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
id="message-input"
onChange={(event) => setDraft(event.target.value)}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message eve-code"
ref={textareaRef}
rows={1}
value={draft}
value={value}
/>
<div className="flex items-center gap-1 pt-1">
{isGenerating && onStop && (
<Button
aria-label="Stop generating"
className="ml-auto size-8 rounded-full"
onClick={onStop}
size="icon-sm"
>
<Square aria-hidden="true" className="fill-current" />
</Button>
)}
{!isGenerating && (
<Button
aria-label="Send message"
className="ml-auto size-8 rounded-full"
disabled={disabled || !draft.trim()}
size="icon-sm"
type="submit"
>
<ArrowUp aria-hidden="true" />
</Button>
)}
<div className="flex min-w-0 items-center justify-end gap-1 pt-1">
<ChatVoiceInput disabled={audioDisabled} onValueChange={onChange} value={value} />
<SubmitButton disabled={submitDisabled} isGenerating={isGenerating} onStop={onStop} />
</div>
</form>
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/session/session-start.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ArrowLeft, ArrowRight, FilePlus2, GitFork, type LucideIcon } from "lucide-react";
import { type FormEvent, type ReactNode, useRef, useState } from "react";

import { Composer } from "@/components/session/composer";
import { Composer } from "@/components/composer/composer";
import { Button } from "@/components/ui/button";
import { type GitRepository, parseGitHubRepository } from "@/lib/github";

Expand Down
2 changes: 1 addition & 1 deletion components/session/session-view.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Activity, lazy, type ReactNode, Suspense, useCallback, useState } from "react";

import { Composer } from "@/components/composer/composer";
import { CommandLogsProvider } from "@/components/session/command-logs";
import { Composer } from "@/components/session/composer";
import { Conversation } from "@/components/session/conversation";
import { PageHeader } from "@/components/session/page-header";
import { type StoredSession, useSession } from "@/components/session/use-session";
Expand Down
57 changes: 57 additions & 0 deletions lib/chat-voice-input/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Chat Voice Input

Chat Voice Input adds live microphone transcription to a React composer. It streams
24 kHz PCM audio to Vercel AI Gateway, writes transcript deltas into a controlled
value, and includes a microphone button, waveform, timer, and error state. It does
not create or persist audio files.

## Usage

Use the default component for the standard controls:

```tsx
import ChatVoiceInput from "@/lib/chat-voice-input";

<ChatVoiceInput
disabled={disabled}
onValueChange={setValue}
value={value}
/>;
```

Use the compound components when the layout needs customization:

```tsx
import ChatVoiceInput, { useChatVoiceInput } from "@/lib/chat-voice-input";

<ChatVoiceInput.Provider
disabled={disabled}
onValueChange={setValue}
value={value}
>
<ChatVoiceInput.Error />
<ChatVoiceInput.Waveform />
<ChatVoiceInput.Timer />
<ChatVoiceInput.Button />
</ChatVoiceInput.Provider>;
```

`useChatVoiceInput` exposes the current status, transcript, media stream, and
`start`/`stop` actions for custom controls. Every component is also available as a
named export.

## Server

The browser expects `POST /eve/v1/transcription` to return a short-lived AI Gateway
token. Pass application-specific configuration to the server-only helper:

```ts
import { createTranscriptionTokenResponse } from "@/lib/chat-voice-input/server";

return createTranscriptionTokenResponse({
apiKey: process.env.TRANSCRIPTION_AI_GATEWAY_API_KEY,
});
```

When `apiKey` is omitted, the AI Gateway provider uses its standard authentication:
`AI_GATEWAY_API_KEY`, then Vercel OIDC.
25 changes: 25 additions & 0 deletions lib/chat-voice-input/chat-voice-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ChatVoiceInputButton, ChatVoiceInputError } from "./controls";
import { ChatVoiceInputProvider, type ChatVoiceInputProviderProps } from "./provider";
import { ChatVoiceInputTimer } from "./timer";
import { ChatVoiceInputWaveform } from "./waveform";

export type ChatVoiceInputProps = Omit<ChatVoiceInputProviderProps, "children">;

function ChatVoiceInput(props: ChatVoiceInputProps) {
return (
<ChatVoiceInputProvider {...props}>
<ChatVoiceInputError />
<ChatVoiceInputWaveform />
<ChatVoiceInputTimer />
<ChatVoiceInputButton />
</ChatVoiceInputProvider>
);
}

export default Object.assign(ChatVoiceInput, {
Button: ChatVoiceInputButton,
Error: ChatVoiceInputError,
Provider: ChatVoiceInputProvider,
Timer: ChatVoiceInputTimer,
Waveform: ChatVoiceInputWaveform,
});
Loading