feat: add Codex TanStack Start demo

This commit is contained in:
CoderLambert
2026-09-11 15:25:44 +08:00
parent 247f56da64
commit 0230ec7cb8
50 changed files with 2658 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# V0 confines Codex to this directory (defaults to process.cwd()).
# Relative paths are resolved from the server process working directory.
CODEX_WORKSPACE_ROOT=.
# Subscription-auth path: do NOT set OPENAI_API_KEY just for this demo.
# Authenticate Codex on the same host first using the Codex CLI ChatGPT sign-in flow.
+9
View File
@@ -0,0 +1,9 @@
node_modules
.output
.tanstack
.env
.env.*
!.env.example
coverage
*.log
.DS_Store
+46
View File
@@ -0,0 +1,46 @@
# Architecture
## Boundary
`@openai/codex-sdk` is server-only. Browser code must never import the SDK or read local Codex authentication files.
## Runtime flow
```text
POST user turn
-> chat server function
-> CodexService
-> startThread() or resumeThread(threadId)
-> runStreamed()
-> normalize Codex ThreadEvent to app ChatEvent
-> async stream back to browser
-> reducer updates message/activity state
```
## Application event contract
The UI depends on an application-owned event contract rather than Codex SDK event shapes. This keeps future Claude/Pi/Qwen adapters possible without rewriting the chat surface.
Initial event families:
- `thread.started`
- `assistant.message`
- `activity.started`
- `activity.updated`
- `activity.completed`
- `turn.completed`
- `error`
## Persistence
V0 browser persistence uses `localStorage` for `threadId` and transcript. Codex owns its own persisted thread state. No application database is introduced in this demo.
## Non-goals
- Multi-user SaaS
- Centralized authentication
- Database/message synchronization
- Multi-agent orchestration
- MCP management UI
- Writable workspace
- Production deployment hardening
+26
View File
@@ -0,0 +1,26 @@
# Five-task development plan
The repository is intentionally split into four parallel implementation tracks plus one integration track. Parallel tasks should minimize overlapping file ownership.
| Task | Scope | Primary file ownership | Deliverable |
|---|---|---|---|
| 01 | Codex runtime | `src/server/codex/**` | thread start/resume + read-only runtime wrapper |
| 02 | Streaming bridge | `src/server-functions/**`, event types | async server stream + normalized `ChatEvent` contract |
| 03 | Chat UI | `src/features/chat/components/**`, route/styles | functional chat page and agent activity rendering |
| 04 | State/persistence/tests | reducer, storage, unit tests | reload/new-chat/error behavior + tests |
| 05 | Integration | whole repository after 01-04 | merge, resolve, install, typecheck/test/build, final archive |
## Merge contract
Tasks 01-04 should produce changes against this base repository independently. Task 05 is the only task allowed to perform broad conflict resolution or refactors spanning multiple modules.
## Definition of done
- No OpenAI API key required for the subscription-auth path.
- First message starts a Codex thread.
- Follow-up message resumes that thread.
- Browser receives meaningful structured progress while a turn runs.
- Transcript and `threadId` survive refresh.
- New Chat resets browser state without deleting Codex's persisted history.
- Failure states are visible and recoverable.
- `npm run typecheck`, `npm test`, and `npm run build` pass in integration.
+47
View File
@@ -0,0 +1,47 @@
# Task 05 integration notes
## Resolved module boundaries
### Task 01 -> Task 02
`src/server-functions/chat.runtime.server.ts` is the only integration seam between the server-function layer and the Codex runtime:
```ts
getCodexRuntime().streamTurn({
prompt: request.message,
threadId: request.threadId,
})
```
The SDK `ThreadEvent` is converted at the seam to Task 02's structural server-only event type. Browser-reachable modules do not import `@openai/codex-sdk`.
### Task 02 -> Task 04
Task 02 owns the transport contract in `src/features/chat/chat.types.ts`.
Task 04 owns deterministic client state in `chat.reducer.ts`.
The single adapter is:
```text
src/features/chat/chat-event.adapter.ts
```
It adds the client receive timestamp required by persisted assistant messages and maps activity completion semantics without leaking Codex SDK shapes into state.
### Task 04 -> Task 03
`useChatController()` owns streaming, reducer dispatch, stale-stream invalidation, restore, and persistence. `ChatPage` is controlled and renders only reducer state.
This removes Task 03's preview-only internal state so there is one source of truth.
## Security boundary
The browser may receive:
- opaque `threadId`
- assistant message text
- sanitized activity summaries/status
- aggregate token usage event (not currently rendered)
- generic/sanitized errors
It does not receive raw reasoning text, command output, MCP arguments/results, local auth data, or arbitrary runtime errors.
+21
View File
@@ -0,0 +1,21 @@
# Validation report
Task 05 integration was performed against the common base commit and all four task patches applied cleanly.
## Completed checks
- `git diff --check`: PASS
- dependency-free strict TypeScript check for the transport/state/persistence/normalizer modules: PASS
- integration smoke test (`Codex-style agent_message -> ChatEvent -> ChatStateEvent -> reducer`): PASS
- browser-boundary smoke assertion that command output and absolute executable paths are not serialized: PASS
- current upstream API shapes reviewed against `@openai/codex-sdk` 0.154.0 and TanStack Start async-generator server-function documentation
## Environment-blocked checks
`npm install` could not reach the npm registry in the integration environment (`registry.npmjs.org` DNS resolution failed). Consequently the requested package-level commands fail before application compilation/testing:
- `npm run typecheck`: blocked because `@types/node` and `vite/client` are not installed
- `npm test`: blocked because `vitest` is not installed
- `npm run build`: blocked because `vite` is not installed
Run `npm install`, then the three commands above in a network-enabled environment before treating the demo as production-ready.
+17
View File
@@ -0,0 +1,17 @@
# Task 01 — Codex runtime
Implement the server-only Codex runtime wrapper.
## Requirements
- Instantiate `Codex` only in server-only code.
- Support `startThread()` and `resumeThread(threadId)`.
- Expose a streaming turn method based on `runStreamed()`.
- Use a read-only sandbox/workspace posture for V0.
- Validate/resolve workspace path server-side.
- Return useful typed errors when Codex authentication or runtime startup fails.
## Primary ownership
`src/server/codex/**`
## Tests
Unit-test input validation and thread selection logic without requiring a live Codex request where practical.
+48
View File
@@ -0,0 +1,48 @@
# Task 02 integration notes
## Delivered contract
Task 02 owns the browser-safe event boundary:
- `src/features/chat/chat.types.ts``ChatRequest`, `ChatEvent`, activity, and usage types.
- `src/server-functions/codex-event.types.ts` — minimal structural mirror of Codex SDK events used only at the server boundary.
- `src/server-functions/codex-event-normalizer.ts` — Codex event -> `ChatEvent[]` normalization.
- `src/server-functions/chat-stream.ts` — async iterable bridge independent of TanStack/Codex runtime details.
- `src/server-functions/chat.server.ts` — TanStack Start POST server function returning an async generator.
- `src/server-functions/chat.runtime.server.ts` — explicit Task 01/05 integration seam.
## Task 05 integration assumption
Task 01 should expose enough behavior to implement this structural port:
```ts
async function* streamCodexTurn(
request: ChatRequest,
): AsyncGenerator<CodexThreadEvent>
```
Expected behavior:
1. If `request.threadId` is absent, call `codex.startThread(...)`.
2. Otherwise call `codex.resumeThread(request.threadId, ...)`.
3. Call `thread.runStreamed(request.message)`.
4. `yield* result.events`.
5. Configure the V0 runtime as read-only in Task 01/05.
No SDK event object should bypass `normalizeCodexEvent` on its way to the browser.
## Deliberate redactions
The normalized browser contract does not expose:
- reasoning text,
- command stdout/stderr,
- MCP arguments or results,
- raw SDK error objects,
- authentication/session file contents.
The opaque Codex `threadId` is exposed because it is required to resume a conversation. It should be treated as an identifier, not an authentication credential.
## Streaming semantics
`agent_message` is emitted only from `item.completed`. Current Codex TypeScript SDK `item.updated` events are item snapshots, not documented text deltas, so concatenating them would risk duplicate message content. Activity events may start/update/complete throughout the turn.
+13
View File
@@ -0,0 +1,13 @@
# Task 02 — Streaming bridge
Create the application event contract and TanStack Start streaming server function.
## Requirements
- Define app-owned `ChatEvent` types.
- Normalize Codex thread/item events into `ChatEvent`.
- Use a TanStack Start server function returning an async stream/async generator.
- Accept `{ message, threadId? }` and validate empty input.
- Never leak opaque auth/session material to the browser.
## Primary ownership
`src/server-functions/**`, `src/features/chat/chat.types.ts`, adapter normalization files agreed with Task 01.
+15
View File
@@ -0,0 +1,15 @@
# Task 03 — Chat UI
Build a focused single-page chat interface.
## Requirements
- User and assistant message list.
- Multiline composer; Enter sends, Shift+Enter inserts newline.
- Disabled/running state and visible errors.
- Compact agent-activity panel for reasoning/tool/command lifecycle events.
- New Chat action.
- Accessible labels and keyboard behavior.
- Avoid adding a large component library.
## Primary ownership
`src/features/chat/components/**`, `src/routes/index.tsx`, `src/styles/app.css`.
+31
View File
@@ -0,0 +1,31 @@
# Task 04 — State, persistence, tests
Implement deterministic client state and local persistence.
## Requirements
- `useReducer`-style state machine for messages, activities, running/error state.
- Persist `{ threadId, messages }` to localStorage with a versioned key.
- Restore safely when persisted JSON is invalid.
- New Chat clears active local conversation state.
- Unit tests for reducer/event application/storage helpers.
## Primary ownership
`src/features/chat/chat.reducer.ts`, `src/features/chat/chat.storage.ts`, tests next to those modules.
## Implemented integration contract
Task 04 exports deterministic client-state primitives from `src/features/chat/chat.reducer.ts`:
- `ChatState`, `ChatMessage`, `AgentActivity`
- `ChatAction` + `chatReducer(state, action)`
- `ChatStateEvent` + `applyChatEvent(state, event)`
- `initialChatState`
Task 05 should adapt Task 02's transport `ChatEvent` into `ChatStateEvent`, then dispatch it as `{ type: 'event.received', event }`. The state layer intentionally has no dependency on `@openai/codex-sdk`.
Persistence helpers live in `src/features/chat/chat.storage.ts`:
- `loadConversation(storage)` safely restores `{ threadId, messages }` and returns `null` on malformed/stale/unavailable storage.
- `saveConversation(storage, conversation)` and `clearConversation(storage)` return a boolean instead of throwing on browser storage failures.
- `conversationFromState(state)` strips transient activity/running/error fields before persistence.
- `getBrowserStorage()` is SSR-safe.
+13
View File
@@ -0,0 +1,13 @@
# Task 05 — Integration and packaging
Integrate outputs from Tasks 01-04 and produce the final runnable archive.
## Requirements
- Apply/merge all module changes against the base repository.
- Resolve interface mismatches centrally rather than duplicating adapters.
- Install dependencies.
- Run `npm run typecheck`, `npm test`, `npm run build`.
- Fix integration-only defects.
- Verify no API key is required in the documented subscription-auth path.
- Update README with exact run/auth instructions and known limitations.
- Create the final downloadable ZIP without `node_modules` or build caches.
+30
View File
@@ -0,0 +1,30 @@
{
"name": "codex-tanstack-demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"start": "node .output/server/index.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@openai/codex-sdk": "^0.154.0",
"@tanstack/react-router": "^1.170.34",
"@tanstack/react-start": "^1.168.51",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/node": "^22.18.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^6.0.2",
"vite": "^8.0.14",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { toChatStateEvent } from './chat-event.adapter'
it('converts an assistant transport event into a persisted reducer message', () => {
expect(
toChatStateEvent(
{ type: 'assistant.message', id: 'm1', text: 'Hello' },
123,
),
).toEqual({
type: 'assistant.message',
message: {
id: 'm1',
role: 'assistant',
content: 'Hello',
createdAt: 123,
},
})
})
describe('activity event mapping', () => {
it('maps completed activities to the reducer completion event', () => {
expect(
toChatStateEvent(
{
type: 'activity.completed',
activity: {
id: 'a1',
kind: 'command',
status: 'completed',
summary: 'Command: npm',
},
},
123,
),
).toEqual({
type: 'activity.completed',
activity: {
id: 'a1',
kind: 'command',
status: 'completed',
summary: 'Command: npm',
},
})
})
})
+43
View File
@@ -0,0 +1,43 @@
import type { ChatEvent } from './chat.types'
import type { ChatStateEvent } from './chat.reducer'
/** Convert the transport contract into deterministic reducer events. */
export function toChatStateEvent(
event: ChatEvent,
receivedAt: number,
): ChatStateEvent {
switch (event.type) {
case 'thread.started':
return event
case 'assistant.message':
return {
type: 'assistant.message',
message: {
id: event.id,
role: 'assistant',
content: event.text,
createdAt: receivedAt,
},
}
case 'activity.started':
case 'activity.updated':
return {
type: event.type,
activity: event.activity,
}
case 'activity.completed':
return {
type: 'activity.completed',
activity: event.activity,
}
case 'turn.completed':
return { type: 'turn.completed' }
case 'error':
return event
}
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import {
applyChatEvent,
chatReducer,
initialChatState,
type AgentActivity,
type ChatMessage,
} from './chat.reducer'
const userMessage: ChatMessage = {
id: 'u1',
role: 'user',
content: 'Hello',
createdAt: 1,
}
const assistantMessage: ChatMessage = {
id: 'a1',
role: 'assistant',
content: 'Hi',
createdAt: 2,
}
const runningActivity: AgentActivity = {
id: 'activity-1',
kind: 'command',
status: 'running',
summary: 'Inspect repository',
}
describe('chatReducer', () => {
it('starts a turn, appends a user message, and clears a previous error', () => {
const errored = {
...initialChatState,
status: 'error' as const,
error: 'old failure',
}
const withMessage = chatReducer(errored, {
type: 'user.message.added',
message: userMessage,
})
const running = chatReducer(withMessage, { type: 'turn.started' })
expect(running.messages).toEqual([userMessage])
expect(running.status).toBe('running')
expect(running.error).toBeNull()
})
it('restores only persisted conversation fields', () => {
const restored = chatReducer(
{
...initialChatState,
status: 'running',
activities: [runningActivity],
},
{
type: 'conversation.restored',
conversation: {
threadId: 'thread-1',
messages: [userMessage],
},
},
)
expect(restored).toEqual({
...initialChatState,
threadId: 'thread-1',
messages: [userMessage],
})
})
it('new chat resets the complete active client conversation state', () => {
const active = {
threadId: 'thread-1',
messages: [userMessage, assistantMessage],
activities: [runningActivity],
status: 'error' as const,
error: 'failed',
}
expect(chatReducer(active, { type: 'new-chat' })).toEqual(initialChatState)
})
})
describe('applyChatEvent', () => {
it('captures the Codex thread id without changing transcript state', () => {
const state = { ...initialChatState, messages: [userMessage] }
const next = applyChatEvent(state, {
type: 'thread.started',
threadId: 'thread-42',
})
expect(next.threadId).toBe('thread-42')
expect(next.messages).toEqual([userMessage])
})
it('upserts assistant messages by id', () => {
const first = applyChatEvent(initialChatState, {
type: 'assistant.message',
message: assistantMessage,
})
const updated = applyChatEvent(first, {
type: 'assistant.message',
message: { ...assistantMessage, content: 'Hi again' },
})
expect(updated.messages).toHaveLength(1)
expect(updated.messages[0]?.content).toBe('Hi again')
})
it('tracks activity lifecycle deterministically', () => {
const started = applyChatEvent(initialChatState, {
type: 'activity.started',
activity: runningActivity,
})
const updated = applyChatEvent(started, {
type: 'activity.updated',
activity: { id: runningActivity.id, summary: 'Running tests' },
})
const completed = applyChatEvent(updated, {
type: 'activity.completed',
activity: {
...runningActivity,
status: 'completed',
summary: 'Running tests',
detail: 'exit 0',
},
})
expect(completed.activities).toEqual([
{
...runningActivity,
status: 'completed',
summary: 'Running tests',
detail: 'exit 0',
},
])
})
it('marks success and failure terminal states', () => {
const running = chatReducer(initialChatState, { type: 'turn.started' })
const completed = applyChatEvent(running, { type: 'turn.completed' })
const failed = applyChatEvent(running, {
type: 'error',
message: 'Codex failed',
})
expect(completed.status).toBe('idle')
expect(completed.error).toBeNull()
expect(failed.status).toBe('error')
expect(failed.error).toBe('Codex failed')
})
})
+176
View File
@@ -0,0 +1,176 @@
export type ChatRunStatus = 'idle' | 'running' | 'error'
export type ChatRole = 'user' | 'assistant'
export interface ChatMessage {
id: string
role: ChatRole
content: string
createdAt: number
}
export type AgentActivityStatus = 'running' | 'completed' | 'failed'
export interface AgentActivity {
id: string
kind: string
status: AgentActivityStatus
summary: string
detail?: string
}
export interface PersistedConversation {
threadId: string | null
messages: ChatMessage[]
}
export interface ChatState extends PersistedConversation {
activities: AgentActivity[]
status: ChatRunStatus
error: string | null
}
/**
* State-layer event contract.
*
* Task 02 owns the transport-level ChatEvent contract. Task 05 should adapt that
* contract to these deterministic state events instead of importing Codex SDK
* shapes into client state.
*/
export type ChatStateEvent =
| { type: 'thread.started'; threadId: string }
| { type: 'assistant.message'; message: ChatMessage }
| { type: 'activity.started'; activity: AgentActivity }
| {
type: 'activity.updated'
activity: Partial<Omit<AgentActivity, 'id'>> & Pick<AgentActivity, 'id'>
}
| { type: 'activity.completed'; activity: AgentActivity }
| { type: 'turn.completed' }
| { type: 'error'; message: string }
export type ChatAction =
| { type: 'user.message.added'; message: ChatMessage }
| { type: 'turn.started' }
| { type: 'event.received'; event: ChatStateEvent }
| { type: 'conversation.restored'; conversation: PersistedConversation }
| { type: 'new-chat' }
export const initialChatState: ChatState = {
threadId: null,
messages: [],
activities: [],
status: 'idle',
error: null,
}
function upsertMessage(messages: ChatMessage[], message: ChatMessage): ChatMessage[] {
const index = messages.findIndex((item) => item.id === message.id)
if (index === -1) {
return [...messages, message]
}
const next = [...messages]
next[index] = message
return next
}
function upsertActivity(
activities: AgentActivity[],
activity: AgentActivity,
): AgentActivity[] {
const index = activities.findIndex((item) => item.id === activity.id)
if (index === -1) {
return [...activities, activity]
}
const next = [...activities]
next[index] = activity
return next
}
export function applyChatEvent(state: ChatState, event: ChatStateEvent): ChatState {
switch (event.type) {
case 'thread.started':
return {
...state,
threadId: event.threadId,
}
case 'assistant.message':
return {
...state,
messages: upsertMessage(state.messages, event.message),
}
case 'activity.started':
return {
...state,
activities: upsertActivity(state.activities, event.activity),
}
case 'activity.updated':
return {
...state,
activities: state.activities.map((activity) =>
activity.id === event.activity.id
? { ...activity, ...event.activity }
: activity,
),
}
case 'activity.completed':
return {
...state,
activities: upsertActivity(state.activities, event.activity),
}
case 'turn.completed':
return {
...state,
status: 'idle',
error: null,
}
case 'error':
return {
...state,
status: 'error',
error: event.message,
}
}
}
export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case 'user.message.added':
return {
...state,
messages: upsertMessage(state.messages, action.message),
error: null,
}
case 'turn.started':
return {
...state,
activities: [],
status: 'running',
error: null,
}
case 'event.received':
return applyChatEvent(state, action.event)
case 'conversation.restored':
return {
...initialChatState,
threadId: action.conversation.threadId,
messages: action.conversation.messages,
}
case 'new-chat':
return initialChatState
}
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import type { ChatState, PersistedConversation } from './chat.reducer'
import {
CHAT_STORAGE_KEY,
CHAT_STORAGE_VERSION,
clearConversation,
conversationFromState,
loadConversation,
parseStoredConversation,
saveConversation,
serializeConversation,
type StorageLike,
} from './chat.storage'
class MemoryStorage implements StorageLike {
readonly values = new Map<string, string>()
getItem(key: string): string | null {
return this.values.get(key) ?? null
}
setItem(key: string, value: string): void {
this.values.set(key, value)
}
removeItem(key: string): void {
this.values.delete(key)
}
}
const conversation: PersistedConversation = {
threadId: 'thread-1',
messages: [
{
id: 'm1',
role: 'user',
content: 'hello',
createdAt: 123,
},
],
}
describe('conversation serialization', () => {
it('round-trips versioned persisted state', () => {
const serialized = serializeConversation(conversation)
expect(parseStoredConversation(serialized)).toEqual(conversation)
expect(JSON.parse(serialized)).toMatchObject({
version: CHAT_STORAGE_VERSION,
conversation: { threadId: 'thread-1' },
})
})
it('rejects malformed JSON, stale versions, and malformed messages', () => {
expect(parseStoredConversation('{bad')).toBeNull()
expect(
parseStoredConversation(
JSON.stringify({ version: 999, conversation }),
),
).toBeNull()
expect(
parseStoredConversation(
JSON.stringify({
version: CHAT_STORAGE_VERSION,
conversation: {
threadId: 'thread-1',
messages: [{ id: 'm1', role: 'bogus', content: 'x', createdAt: 1 }],
},
}),
),
).toBeNull()
})
})
describe('storage helpers', () => {
it('saves, loads, and clears the active conversation', () => {
const storage = new MemoryStorage()
saveConversation(storage, conversation)
expect(loadConversation(storage)).toEqual(conversation)
clearConversation(storage)
expect(loadConversation(storage)).toBeNull()
})
it('removes invalid persisted data during safe restore', () => {
const storage = new MemoryStorage()
storage.setItem(CHAT_STORAGE_KEY, 'not-json')
expect(loadConversation(storage)).toBeNull()
expect(storage.getItem(CHAT_STORAGE_KEY)).toBeNull()
})
it('does not throw when browser storage itself is unavailable', () => {
const unavailable: StorageLike = {
getItem() { throw new Error('blocked') },
setItem() { throw new Error('blocked') },
removeItem() { throw new Error('blocked') },
}
expect(loadConversation(unavailable)).toBeNull()
expect(saveConversation(unavailable, conversation)).toBe(false)
expect(clearConversation(unavailable)).toBe(false)
})
it('persists only threadId and messages from runtime state', () => {
const state: ChatState = {
...conversation,
activities: [
{
id: 'a1',
kind: 'reasoning',
status: 'running',
summary: 'thinking',
},
],
status: 'running',
error: 'transient',
}
expect(conversationFromState(state)).toEqual(conversation)
})
})
+126
View File
@@ -0,0 +1,126 @@
import type { ChatMessage, ChatState, PersistedConversation } from './chat.reducer'
export const CHAT_STORAGE_VERSION = 1 as const
export const CHAT_STORAGE_KEY = `codex-tanstack-demo:conversation:v${CHAT_STORAGE_VERSION}`
interface StoredConversationEnvelope {
version: typeof CHAT_STORAGE_VERSION
conversation: PersistedConversation
}
export interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isChatMessage(value: unknown): value is ChatMessage {
if (!isRecord(value)) return false
return (
typeof value.id === 'string' &&
(value.role === 'user' || value.role === 'assistant') &&
typeof value.content === 'string' &&
typeof value.createdAt === 'number' &&
Number.isFinite(value.createdAt)
)
}
export function parseStoredConversation(raw: string): PersistedConversation | null {
try {
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed)) return null
if (parsed.version !== CHAT_STORAGE_VERSION) return null
if (!isRecord(parsed.conversation)) return null
const { threadId, messages } = parsed.conversation
if (!(threadId === null || typeof threadId === 'string')) return null
if (!Array.isArray(messages) || !messages.every(isChatMessage)) return null
return {
threadId,
messages: messages.map((message) => ({ ...message })),
}
} catch {
return null
}
}
export function serializeConversation(
conversation: PersistedConversation,
): string {
const envelope: StoredConversationEnvelope = {
version: CHAT_STORAGE_VERSION,
conversation: {
threadId: conversation.threadId,
messages: conversation.messages,
},
}
return JSON.stringify(envelope)
}
export function conversationFromState(state: ChatState): PersistedConversation {
return {
threadId: state.threadId,
messages: state.messages,
}
}
export function loadConversation(storage: StorageLike): PersistedConversation | null {
try {
const raw = storage.getItem(CHAT_STORAGE_KEY)
if (raw === null) return null
const conversation = parseStoredConversation(raw)
// Invalid or stale persisted data should not poison every future page load.
if (conversation === null) {
try {
storage.removeItem(CHAT_STORAGE_KEY)
} catch {
// Best-effort cleanup only; restore must remain safe.
}
}
return conversation
} catch {
return null
}
}
export function saveConversation(
storage: StorageLike,
conversation: PersistedConversation,
): boolean {
try {
storage.setItem(CHAT_STORAGE_KEY, serializeConversation(conversation))
return true
} catch {
return false
}
}
export function clearConversation(storage: StorageLike): boolean {
try {
storage.removeItem(CHAT_STORAGE_KEY)
return true
} catch {
return false
}
}
/** Browser-safe storage lookup. Useful for SSR-aware TanStack Start components. */
export function getBrowserStorage(): StorageLike | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
+61
View File
@@ -0,0 +1,61 @@
export type ChatRequest = {
message: string
threadId?: string
}
export type ChatUsage = {
inputTokens: number
cachedInputTokens: number
cacheWriteInputTokens: number
outputTokens: number
reasoningOutputTokens: number
}
export type AgentActivityKind =
| 'reasoning'
| 'command'
| 'file'
| 'tool'
| 'web'
| 'todo'
| 'error'
export type AgentActivityStatus = 'running' | 'completed' | 'failed'
export type AgentActivity = {
id: string
kind: AgentActivityKind
status: AgentActivityStatus
summary: string
}
export type ChatEvent =
| {
type: 'thread.started'
threadId: string
}
| {
type: 'assistant.message'
id: string
text: string
}
| {
type: 'activity.started'
activity: AgentActivity
}
| {
type: 'activity.updated'
activity: AgentActivity
}
| {
type: 'activity.completed'
activity: AgentActivity
}
| {
type: 'turn.completed'
usage: ChatUsage
}
| {
type: 'error'
message: string
}
@@ -0,0 +1,46 @@
import type { AgentActivity as AgentActivityModel } from '../chat.reducer'
interface AgentActivityProps {
activities: AgentActivityModel[]
}
const kindLabel: Record<string, string> = {
reasoning: 'Reasoning',
tool: 'Tool',
command: 'Command',
file: 'File',
web: 'Web',
todo: 'Plan',
error: 'Error',
}
export function AgentActivity({ activities }: AgentActivityProps) {
if (activities.length === 0) return null
return (
<aside className="activity" aria-label="Agent activity">
<div className="activity__header">
<span>Agent activity</span>
<span className="activity__count">{activities.length}</span>
</div>
<ol className="activity__list">
{activities.map((activity) => (
<li className="activity__item" key={activity.id}>
<span
className={`activity__status activity__status--${activity.status}`}
aria-label={activity.status}
title={activity.status}
/>
<div className="activity__body">
<div className="activity__title-row">
<span className="activity__kind">{kindLabel[activity.kind] ?? activity.kind}</span>
<span className="activity__label">{activity.summary}</span>
</div>
{activity.detail ? <p>{activity.detail}</p> : null}
</div>
</li>
))}
</ol>
</aside>
)
}
@@ -0,0 +1,68 @@
import { useId, useRef, useState, type FormEvent, type KeyboardEvent } from 'react'
interface ChatInputProps {
disabled?: boolean
onSubmit: (value: string) => void | Promise<void>
}
export function ChatInput({ disabled = false, onSubmit }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const hintId = useId()
const canSend = value.trim().length > 0 && !disabled
async function submit() {
const nextValue = value.trim()
if (!nextValue || disabled) return
setValue('')
await onSubmit(nextValue)
textareaRef.current?.focus()
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
void submit()
}
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
void submit()
}
}
return (
<form className="composer" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="chat-composer">
Codex
</label>
<textarea
ref={textareaRef}
id="chat-composer"
className="composer__input"
value={value}
rows={1}
placeholder="Ask Codex about your project…"
aria-describedby={hintId}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
/>
<button className="send-button" type="submit" disabled={!canSend} aria-label="发送消息">
<SendIcon />
</button>
<span id={hintId} className="composer__hint">
Enter · Shift+Enter
</span>
</form>
)
}
function SendIcon() {
return (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 19V5M6 11l6-6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
@@ -0,0 +1,21 @@
import type { ChatMessage as ChatMessageModel } from '../chat.reducer'
interface ChatMessageProps {
message: ChatMessageModel
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user'
return (
<article className={`message message--${message.role}`} aria-label={isUser ? '你的消息' : 'Codex 回复'}>
<div className="message__meta">
<span className={`avatar avatar--${message.role}`} aria-hidden="true">
{isUser ? 'Y' : 'C'}
</span>
<span>{isUser ? 'You' : 'Codex'}</span>
</div>
<div className="message__content">{message.content}</div>
</article>
)
}
@@ -0,0 +1,93 @@
import { useEffect, useRef } from 'react'
import { AgentActivity } from './agent-activity'
import { ChatInput } from './chat-input'
import { ChatMessage } from './chat-message'
import type { ChatPageProps } from './chat-ui.types'
export function ChatPage({
title = 'Codex Chat',
subtitle = 'Personal agent runtime demo',
messages,
activities,
status,
error,
onSend,
onNewChat,
}: ChatPageProps) {
const scrollAnchorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollAnchorRef.current?.scrollIntoView({ block: 'end' })
}, [messages, activities, status])
return (
<main className="chat-shell">
<header className="topbar">
<div>
<div className="brand-row">
<span className="brand-mark" aria-hidden="true">C</span>
<h1>{title}</h1>
</div>
<p>{subtitle}</p>
</div>
<button className="secondary-button" type="button" onClick={onNewChat}>
<PlusIcon />
New Chat
</button>
</header>
<section className="chat-panel" aria-label="Chat conversation">
<div className="conversation" aria-live="polite" aria-busy={status === 'running'}>
<div className="conversation__inner">
{messages.length === 0 ? <WelcomeMessage /> : null}
{messages.map((message) => <ChatMessage key={message.id} message={message} />)}
{status === 'running' ? <RunningIndicator /> : null}
<AgentActivity activities={activities} />
{error ? (
<div className="error-banner" role="alert">
<strong>Request failed</strong>
<span>{error}</span>
</div>
) : null}
<div ref={scrollAnchorRef} />
</div>
</div>
<div className="composer-wrap">
<ChatInput disabled={status === 'running'} onSubmit={onSend} />
</div>
</section>
</main>
)
}
function WelcomeMessage() {
return (
<div className="message message--assistant">
<div className="message__meta">
<span className="avatar avatar--assistant" aria-hidden="true">C</span>
<span>Codex</span>
</div>
<div className="message__content">
Ready when you are. Ask me to inspect, explain, or reason about this project.
</div>
</div>
)
}
function RunningIndicator() {
return (
<div className="running-indicator" role="status">
<span className="running-indicator__dot" />
<span>Codex is working</span>
</div>
)
}
function PlusIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
}
@@ -0,0 +1,12 @@
import type { AgentActivity, ChatMessage, ChatRunStatus } from '../chat.reducer'
export interface ChatPageProps {
title?: string
subtitle?: string
messages: ChatMessage[]
activities: AgentActivity[]
status: ChatRunStatus
error: string | null
onSend: (message: string) => Promise<void>
onNewChat: () => void
}
+102
View File
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { streamChat } from '../../server-functions/chat.server'
import { toChatStateEvent } from './chat-event.adapter'
import {
chatReducer,
initialChatState,
type ChatMessage,
type ChatState,
} from './chat.reducer'
import {
clearConversation,
conversationFromState,
getBrowserStorage,
loadConversation,
saveConversation,
} from './chat.storage'
export interface ChatController {
state: ChatState
sendMessage: (content: string) => Promise<void>
newChat: () => void
}
export function useChatController(): ChatController {
const [state, dispatch] = useReducer(chatReducer, initialChatState)
const [restored, setRestored] = useState(false)
const stateRef = useRef(state)
const generationRef = useRef(0)
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
const storage = getBrowserStorage()
const conversation = storage ? loadConversation(storage) : null
if (conversation) {
dispatch({ type: 'conversation.restored', conversation })
}
setRestored(true)
}, [])
useEffect(() => {
if (!restored) return
const storage = getBrowserStorage()
if (storage) saveConversation(storage, conversationFromState(state))
}, [restored, state.threadId, state.messages])
const sendMessage = useCallback(async (content: string) => {
if (stateRef.current.status === 'running') return
const generation = generationRef.current
const userMessage: ChatMessage = {
id: createId('user'),
role: 'user',
content,
createdAt: Date.now(),
}
dispatch({ type: 'user.message.added', message: userMessage })
dispatch({ type: 'turn.started' })
try {
const events = await streamChat({
data: {
message: content,
threadId: stateRef.current.threadId ?? undefined,
},
})
for await (const event of events) {
if (generation !== generationRef.current) return
dispatch({
type: 'event.received',
event: toChatStateEvent(event, Date.now()),
})
}
} catch (error) {
if (generation !== generationRef.current) return
dispatch({
type: 'event.received',
event: {
type: 'error',
message: error instanceof Error ? error.message : 'Codex request failed.',
},
})
}
}, [])
const newChat = useCallback(() => {
generationRef.current += 1
dispatch({ type: 'new-chat' })
const storage = getBrowserStorage()
if (storage) clearConversation(storage)
}, [])
return { state, sendMessage, newChat }
}
function createId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
+45
View File
@@ -0,0 +1,45 @@
/* eslint-disable */
// @ts-nocheck
// Minimal generated-style route tree. TanStack's Vite plugin may overwrite this file.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRouteTypes {
fileRoutesByFullPath: { '/': typeof IndexRoute }
fullPaths: '/'
fileRoutesByTo: { '/': typeof IndexRoute }
to: '/'
id: '__root__' | '/'
fileRoutesById: { __root__: typeof rootRouteImport; '/': typeof IndexRoute }
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren = { IndexRoute }
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+16
View File
@@ -0,0 +1,16 @@
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
})
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'
import appCss from '../styles/app.css?url'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'Codex + TanStack Start Demo' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootComponent,
})
function RootComponent() {
return (
<RootDocument>
<Outlet />
</RootDocument>
)
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-CN">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import { ChatPage } from '../features/chat/components/chat-page'
import { useChatController } from '../features/chat/use-chat-controller'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
const { state, sendMessage, newChat } = useChatController()
return (
<ChatPage
messages={state.messages}
activities={state.activities}
status={state.status}
error={state.error}
onSend={sendMessage}
onNewChat={newChat}
/>
)
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { CodexThreadEvent } from '../codex-event.types'
import { streamNormalizedChatEvents } from '../chat-stream'
describe('streamNormalizedChatEvents', () => {
it('flattens normalized Codex events in source order', async () => {
const events: CodexThreadEvent[] = [
{ type: 'thread.started', thread_id: 'thread-1' },
{ type: 'turn.started' },
{
type: 'item.completed',
item: { id: 'm1', type: 'agent_message', text: 'Done' },
},
{
type: 'turn.completed',
usage: {
input_tokens: 1,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 2,
reasoning_output_tokens: 0,
},
},
]
async function* source() {
yield* events
}
const output = []
for await (const event of streamNormalizedChatEvents(
{ message: 'hello' },
async () => source(),
)) {
output.push(event)
}
expect(output.map((event) => event.type)).toEqual([
'thread.started',
'assistant.message',
'turn.completed',
])
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { validateChatRequest } from '../chat.validation'
describe('validateChatRequest', () => {
it('trims a valid message and optional thread id', () => {
expect(validateChatRequest({ message: ' hello ', threadId: ' thread-123 ' })).toEqual({
message: 'hello',
threadId: 'thread-123',
})
})
it('rejects empty messages', () => {
expect(() => validateChatRequest({ message: ' ' })).toThrow('Message cannot be empty.')
})
it('rejects thread ids containing whitespace', () => {
expect(() => validateChatRequest({ message: 'hello', threadId: 'bad id' })).toThrow(
'Thread ID is invalid.',
)
})
})
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { normalizeCodexEvent, sanitizeErrorMessage } from '../codex-event-normalizer'
describe('normalizeCodexEvent', () => {
it('maps thread ids and usage without exposing SDK field names', () => {
expect(normalizeCodexEvent({ type: 'thread.started', thread_id: 'thread-1' })).toEqual([
{ type: 'thread.started', threadId: 'thread-1' },
])
expect(
normalizeCodexEvent({
type: 'turn.completed',
usage: {
input_tokens: 10,
cached_input_tokens: 3,
output_tokens: 5,
reasoning_output_tokens: 2,
},
}),
).toEqual([
{
type: 'turn.completed',
usage: {
inputTokens: 10,
cachedInputTokens: 3,
cacheWriteInputTokens: 0,
outputTokens: 5,
reasoningOutputTokens: 2,
},
},
])
})
it('emits assistant text only when the item completes', () => {
const item = { id: 'm1', type: 'agent_message' as const, text: 'Hello' }
expect(normalizeCodexEvent({ type: 'item.updated', item })).toEqual([])
expect(normalizeCodexEvent({ type: 'item.completed', item })).toEqual([
{ type: 'assistant.message', id: 'm1', text: 'Hello' },
])
})
it('does not expose reasoning text, command output, or MCP payloads', () => {
const reasoning = normalizeCodexEvent({
type: 'item.started',
item: { id: 'r1', type: 'reasoning', text: 'private reasoning details' },
})
expect(JSON.stringify(reasoning)).not.toContain('private reasoning details')
const command = normalizeCodexEvent({
type: 'item.updated',
item: {
id: 'c1',
type: 'command_execution',
command: 'npm test -- --run',
aggregated_output: 'SECRET_OUTPUT',
status: 'in_progress',
},
})
expect(command).toEqual([
{
type: 'activity.updated',
activity: { id: 'c1', kind: 'command', status: 'running', summary: 'Command: npm' },
},
])
expect(JSON.stringify(command)).not.toContain('SECRET_OUTPUT')
const tool = normalizeCodexEvent({
type: 'item.completed',
item: {
id: 't1',
type: 'mcp_tool_call',
server: 'github',
tool: 'search',
arguments: { token: 'DO_NOT_LEAK' },
result: { secret: 'DO_NOT_LEAK' },
status: 'completed',
},
})
expect(JSON.stringify(tool)).not.toContain('DO_NOT_LEAK')
})
it('redacts common credential-looking values in runtime errors', () => {
const message = sanitizeErrorMessage(
'request failed: Bearer abc.DEF-123 token-supersecretvalue12345',
)
expect(message).toContain('Bearer [redacted]')
expect(message).not.toContain('supersecretvalue12345')
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { ChatEvent, ChatRequest } from '../features/chat/chat.types'
import type { CodexThreadEvent } from './codex-event.types'
import { normalizeCodexEvent } from './codex-event-normalizer'
export type CodexTurnStreamFactory = (
request: ChatRequest,
) => AsyncIterable<CodexThreadEvent> | Promise<AsyncIterable<CodexThreadEvent>>
/** Pure bridge used by the TanStack Start server function and unit tests. */
export async function* streamNormalizedChatEvents(
request: ChatRequest,
createCodexTurnStream: CodexTurnStreamFactory,
): AsyncGenerator<ChatEvent> {
const source = await createCodexTurnStream(request)
for await (const codexEvent of source) {
for (const chatEvent of normalizeCodexEvent(codexEvent)) {
yield chatEvent
}
}
}
@@ -0,0 +1,23 @@
import type { ChatRequest } from '../features/chat/chat.types'
import { getCodexRuntime } from '../server/codex/index.server'
import type { CodexThreadEvent } from './codex-event.types'
/**
* Server-only integration seam between the browser-safe streaming bridge and
* the Codex runtime. Authentication stays entirely inside the local Codex SDK
* process; no API key or auth material crosses this boundary.
*/
export async function* streamCodexTurn(
request: ChatRequest,
): AsyncGenerator<CodexThreadEvent> {
const runtime = getCodexRuntime()
for await (const event of runtime.streamTurn({
prompt: request.message,
threadId: request.threadId,
})) {
// Task 02 intentionally owns a small structural mirror of the SDK event
// contract so browser-reachable modules never import @openai/codex-sdk.
yield event as unknown as CodexThreadEvent
}
}
+26
View File
@@ -0,0 +1,26 @@
import { createServerFn } from '@tanstack/react-start'
import { streamCodexTurn } from './chat.runtime.server'
import { streamNormalizedChatEvents } from './chat-stream'
import { validateChatRequest } from './chat.validation'
/**
* Type-safe streaming RPC for the browser.
*
* TanStack Start executes the handler only on the server and serializes yielded
* `ChatEvent` values to the caller as an async stream.
*/
export const streamChat = createServerFn({ method: 'POST' })
.inputValidator(validateChatRequest)
.handler(async function* ({ data }) {
try {
yield* streamNormalizedChatEvents(data, streamCodexTurn)
} catch (error) {
// Do not serialize arbitrary runtime errors. They may include local paths,
// process details, or credentials. Keep detailed diagnostics server-side.
console.error('Codex chat stream failed', error)
yield {
type: 'error' as const,
message: 'Codex turn failed. Check the server logs for details.',
}
}
})
+42
View File
@@ -0,0 +1,42 @@
import type { ChatRequest } from '../features/chat/chat.types'
const MAX_MESSAGE_LENGTH = 32_000
const MAX_THREAD_ID_LENGTH = 512
export function validateChatRequest(input: unknown): ChatRequest {
if (!isRecord(input)) {
throw new Error('Chat request must be an object.')
}
if (typeof input.message !== 'string') {
throw new Error('Message must be a string.')
}
const message = input.message.trim()
if (message.length === 0) {
throw new Error('Message cannot be empty.')
}
if (message.length > MAX_MESSAGE_LENGTH) {
throw new Error(`Message must be at most ${MAX_MESSAGE_LENGTH} characters.`)
}
if (input.threadId === undefined || input.threadId === null || input.threadId === '') {
return { message }
}
if (typeof input.threadId !== 'string') {
throw new Error('Thread ID must be a string.')
}
const threadId = input.threadId.trim()
if (threadId.length === 0 || threadId.length > MAX_THREAD_ID_LENGTH || /\s/.test(threadId)) {
throw new Error('Thread ID is invalid.')
}
return { message, threadId }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -0,0 +1,159 @@
import type {
AgentActivity,
AgentActivityKind,
AgentActivityStatus,
ChatEvent,
ChatUsage,
} from '../features/chat/chat.types'
import type { CodexThreadEvent, CodexThreadItem, CodexUsage } from './codex-event.types'
/**
* Converts Codex SDK events into the stable, browser-safe application contract.
*
* Security boundary:
* - raw SDK objects are never forwarded
* - command stdout/stderr is never forwarded
* - MCP arguments/results are never forwarded
* - reasoning text is not forwarded
* - only an opaque thread identifier required for resume is exposed
*/
export function normalizeCodexEvent(event: CodexThreadEvent): ChatEvent[] {
switch (event.type) {
case 'thread.started':
return [{ type: 'thread.started', threadId: event.thread_id }]
case 'turn.started':
return []
case 'turn.completed':
return [{ type: 'turn.completed', usage: normalizeUsage(event.usage) }]
case 'turn.failed':
return [{ type: 'error', message: sanitizeErrorMessage(event.error.message) }]
case 'error':
return [{ type: 'error', message: sanitizeErrorMessage(event.message) }]
case 'item.started':
case 'item.updated':
case 'item.completed':
return normalizeItemEvent(event.type, event.item)
}
}
function normalizeItemEvent(
eventType: 'item.started' | 'item.updated' | 'item.completed',
item: CodexThreadItem,
): ChatEvent[] {
if (item.type === 'agent_message') {
return eventType === 'item.completed'
? [{ type: 'assistant.message', id: item.id, text: item.text }]
: []
}
const normalizedActivity = toActivity(item)
if (!normalizedActivity) {
return []
}
const activity =
eventType === 'item.completed' && normalizedActivity.status === 'running'
? { ...normalizedActivity, status: 'completed' as const }
: normalizedActivity
if (eventType === 'item.started') {
return [{ type: 'activity.started', activity }]
}
if (eventType === 'item.updated') {
return [{ type: 'activity.updated', activity }]
}
return [{ type: 'activity.completed', activity }]
}
function toActivity(item: Exclude<CodexThreadItem, { type: 'agent_message' }>): AgentActivity | null {
switch (item.type) {
case 'reasoning':
return activity(item.id, 'reasoning', 'running', 'Reasoning')
case 'command_execution':
return activity(
item.id,
'command',
mapRuntimeStatus(item.status),
summarizeCommand(item.command),
)
case 'file_change':
return activity(
item.id,
'file',
item.status === 'failed' ? 'failed' : 'completed',
`File changes: ${item.changes.length}`,
)
case 'mcp_tool_call':
return activity(
item.id,
'tool',
mapRuntimeStatus(item.status),
`Tool: ${safeLabel(item.server)} / ${safeLabel(item.tool)}`,
)
case 'web_search':
return activity(item.id, 'web', 'completed', 'Web search')
case 'todo_list': {
const completed = item.items.filter((todo) => todo.completed).length
return activity(item.id, 'todo', 'running', `Plan progress: ${completed}/${item.items.length}`)
}
case 'error':
return activity(item.id, 'error', 'failed', sanitizeErrorMessage(item.message))
}
}
function activity(
id: string,
kind: AgentActivityKind,
status: AgentActivityStatus,
summary: string,
): AgentActivity {
return { id, kind, status, summary }
}
function mapRuntimeStatus(status: 'in_progress' | 'completed' | 'failed'): AgentActivityStatus {
if (status === 'in_progress') return 'running'
return status
}
function normalizeUsage(usage: CodexUsage): ChatUsage {
return {
inputTokens: usage.input_tokens,
cachedInputTokens: usage.cached_input_tokens,
cacheWriteInputTokens: usage.cache_write_input_tokens ?? 0,
outputTokens: usage.output_tokens,
reasoningOutputTokens: usage.reasoning_output_tokens,
}
}
function summarizeCommand(command: string): string {
const executable = command.trim().split(/\s+/u, 1)[0]
if (!executable) return 'Command'
const basename = executable.split(/[\\/]/u).at(-1) ?? executable
return `Command: ${safeLabel(basename)}`
}
function safeLabel(value: string): string {
return value.replace(/[\r\n\t]/gu, ' ').slice(0, 120)
}
export function sanitizeErrorMessage(message: string): string {
const normalized = message.replace(/[\r\n\t]+/gu, ' ').trim()
const redacted = normalized
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/giu, 'Bearer [redacted]')
.replace(/\b(?:sk|sess|session|token)[-_][A-Za-z0-9._-]{12,}\b/giu, '[redacted]')
return redacted.slice(0, 500) || 'Codex turn failed.'
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Deliberately small structural mirror of the Codex SDK event contract.
*
* Task 02 does not import `@openai/codex-sdk` into browser-reachable modules.
* Task 01/05 can pass the SDK's `ThreadEvent` values directly because these
* shapes are structurally compatible.
*/
export type CodexUsage = {
input_tokens: number
cached_input_tokens: number
cache_write_input_tokens?: number
output_tokens: number
reasoning_output_tokens: number
}
export type CodexThreadItem =
| {
id: string
type: 'agent_message'
text: string
}
| {
id: string
type: 'reasoning'
text: string
}
| {
id: string
type: 'command_execution'
command: string
aggregated_output: string
exit_code?: number
status: 'in_progress' | 'completed' | 'failed'
}
| {
id: string
type: 'file_change'
changes: Array<{ path: string; kind: 'add' | 'delete' | 'update' }>
status: 'completed' | 'failed'
}
| {
id: string
type: 'mcp_tool_call'
server: string
tool: string
arguments: unknown
result?: unknown
error?: { message: string }
status: 'in_progress' | 'completed' | 'failed'
}
| {
id: string
type: 'web_search'
query: string
}
| {
id: string
type: 'todo_list'
items: Array<{ text: string; completed: boolean }>
}
| {
id: string
type: 'error'
message: string
}
export type CodexThreadEvent =
| { type: 'thread.started'; thread_id: string }
| { type: 'turn.started' }
| { type: 'turn.completed'; usage: CodexUsage }
| { type: 'turn.failed'; error: { message: string } }
| { type: 'item.started'; item: CodexThreadItem }
| { type: 'item.updated'; item: CodexThreadItem }
| { type: 'item.completed'; item: CodexThreadItem }
| { type: 'error'; message: string }
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { normalizeCodexRuntimeError } from "./codex.errors";
describe("normalizeCodexRuntimeError", () => {
it("classifies authentication failures", () => {
expect(normalizeCodexRuntimeError(new Error("401 Unauthorized"))).toMatchObject({
code: "AUTH_REQUIRED",
});
});
it("classifies spawn failures", () => {
expect(normalizeCodexRuntimeError(new Error("spawn codex ENOENT"))).toMatchObject({
code: "RUNTIME_START_FAILED",
});
});
});
+60
View File
@@ -0,0 +1,60 @@
export type CodexRuntimeErrorCode =
| "INVALID_INPUT"
| "INVALID_WORKSPACE"
| "AUTH_REQUIRED"
| "RUNTIME_START_FAILED"
| "CODEX_RUNTIME_FAILED";
export class CodexRuntimeError extends Error {
readonly code: CodexRuntimeErrorCode;
readonly cause?: unknown;
constructor(code: CodexRuntimeErrorCode, message: string, cause?: unknown) {
super(message);
this.name = "CodexRuntimeError";
this.code = code;
this.cause = cause;
}
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown Codex runtime error";
}
export function normalizeCodexRuntimeError(error: unknown): CodexRuntimeError {
if (error instanceof CodexRuntimeError) return error;
const message = errorMessage(error);
const normalized = message.toLowerCase();
if (
normalized.includes("not logged in") ||
normalized.includes("login required") ||
normalized.includes("authentication") ||
normalized.includes("unauthorized") ||
normalized.includes("401")
) {
return new CodexRuntimeError(
"AUTH_REQUIRED",
"Codex is not authenticated. Sign in with the Codex CLI/ChatGPT account on the server host, then retry.",
error,
);
}
if (
normalized.includes("enoent") ||
normalized.includes("spawn") ||
normalized.includes("executable") ||
normalized.includes("permission denied")
) {
return new CodexRuntimeError(
"RUNTIME_START_FAILED",
`Unable to start the Codex runtime: ${message}`,
error,
);
}
return new CodexRuntimeError("CODEX_RUNTIME_FAILED", message, error);
}
+83
View File
@@ -0,0 +1,83 @@
import { Codex, type ThreadEvent } from "@openai/codex-sdk";
import { CodexRuntimeError, normalizeCodexRuntimeError } from "./codex.errors";
import {
normalizeThreadId,
selectThread,
type CodexClientLike,
type ThreadOptionsLike,
} from "./thread-selection.server";
import { resolveWorkspace } from "./workspace.server";
export type StreamCodexTurnInput = {
prompt: string;
threadId?: string | null;
workspacePath?: string;
signal?: AbortSignal;
};
export type CodexRuntimeOptions = {
workspaceRoot?: string;
};
const READ_ONLY_THREAD_OPTIONS = {
sandboxMode: "read-only",
approvalPolicy: "never",
networkAccessEnabled: false,
} as const;
function normalizePrompt(prompt: string): string {
const normalized = prompt.trim();
if (!normalized) {
throw new CodexRuntimeError("INVALID_INPUT", "Prompt must not be empty.");
}
return normalized;
}
/**
* Server-only wrapper around @openai/codex-sdk.
*
* Authentication is intentionally not accepted from the browser. The SDK reuses
* the Codex/ChatGPT authentication available to the server-side Codex runtime.
*/
export class CodexRuntime {
private readonly client: CodexClientLike<ThreadEvent>;
private readonly workspaceRoot?: string;
constructor(options: CodexRuntimeOptions = {}, client?: CodexClientLike<ThreadEvent>) {
this.workspaceRoot = options.workspaceRoot;
this.client = client ?? (new Codex() as CodexClientLike<ThreadEvent>);
}
async *streamTurn(input: StreamCodexTurnInput): AsyncGenerator<ThreadEvent> {
const prompt = normalizePrompt(input.prompt);
const threadId = normalizeThreadId(input.threadId);
const workingDirectory = await resolveWorkspace({
requestedPath: input.workspacePath,
allowedRoot: this.workspaceRoot,
});
const threadOptions: ThreadOptionsLike = {
...READ_ONLY_THREAD_OPTIONS,
workingDirectory,
};
const thread = selectThread(this.client, threadId, threadOptions);
try {
const { events } = await thread.runStreamed(prompt, { signal: input.signal });
for await (const event of events) {
yield event;
}
} catch (error) {
throw normalizeCodexRuntimeError(error);
}
}
}
let singleton: CodexRuntime | undefined;
export function getCodexRuntime(): CodexRuntime {
singleton ??= new CodexRuntime();
return singleton;
}
+4
View File
@@ -0,0 +1,4 @@
export { CodexRuntime, getCodexRuntime } from "./codex.server";
export type { CodexRuntimeOptions, StreamCodexTurnInput } from "./codex.server";
export { CodexRuntimeError, normalizeCodexRuntimeError } from "./codex.errors";
export type { CodexRuntimeErrorCode } from "./codex.errors";
@@ -0,0 +1,44 @@
import { CodexRuntimeError } from "./codex.errors";
export type ThreadOptionsLike = {
sandboxMode: "read-only";
workingDirectory: string;
approvalPolicy: "never";
networkAccessEnabled: false;
};
export type CodexThreadLike<TEvent = unknown> = {
readonly id: string | null;
runStreamed(
input: string,
options?: { signal?: AbortSignal },
): Promise<{ events: AsyncGenerator<TEvent> }>;
};
export type CodexClientLike<TEvent = unknown> = {
startThread(options: ThreadOptionsLike): CodexThreadLike<TEvent>;
resumeThread(threadId: string, options: ThreadOptionsLike): CodexThreadLike<TEvent>;
};
const THREAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
export function normalizeThreadId(threadId?: string | null): string | undefined {
if (threadId == null) return undefined;
const normalized = threadId.trim();
if (!normalized) return undefined;
if (!THREAD_ID_PATTERN.test(normalized)) {
throw new CodexRuntimeError("INVALID_INPUT", "Invalid Codex thread id.");
}
return normalized;
}
export function selectThread<TEvent>(
client: CodexClientLike<TEvent>,
threadId: string | undefined,
options: ThreadOptionsLike,
): CodexThreadLike<TEvent> {
return threadId ? client.resumeThread(threadId, options) : client.startThread(options);
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import {
normalizeThreadId,
selectThread,
type CodexClientLike,
type CodexThreadLike,
type ThreadOptionsLike,
} from "./thread-selection.server";
function fakeThread(id: string | null): CodexThreadLike {
return {
id,
async runStreamed() {
async function* events() {
// no-op fake stream
}
return { events: events() };
},
};
}
const options: ThreadOptionsLike = {
sandboxMode: "read-only",
approvalPolicy: "never",
networkAccessEnabled: false,
workingDirectory: "/workspace",
};
describe("normalizeThreadId", () => {
it("treats missing and blank ids as a new thread", () => {
expect(normalizeThreadId()).toBeUndefined();
expect(normalizeThreadId(" ")).toBeUndefined();
});
it("trims a valid id", () => {
expect(normalizeThreadId(" abc-123 ")).toBe("abc-123");
});
it("rejects malformed ids", () => {
expect(() => normalizeThreadId("../session")).toThrow("Invalid Codex thread id");
});
});
describe("selectThread", () => {
it("starts a new thread when no id is present", () => {
const started = fakeThread(null);
const client: CodexClientLike = {
startThread: vi.fn(() => started),
resumeThread: vi.fn(() => fakeThread("unused")),
};
expect(selectThread(client, undefined, options)).toBe(started);
expect(client.startThread).toHaveBeenCalledWith(options);
expect(client.resumeThread).not.toHaveBeenCalled();
});
it("resumes the requested thread", () => {
const resumed = fakeThread("thread-123");
const client: CodexClientLike = {
startThread: vi.fn(() => fakeThread(null)),
resumeThread: vi.fn(() => resumed),
};
expect(selectThread(client, "thread-123", options)).toBe(resumed);
expect(client.resumeThread).toHaveBeenCalledWith("thread-123", options);
expect(client.startThread).not.toHaveBeenCalled();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
import { CodexRuntimeError } from "./codex.errors";
export type ResolveWorkspaceOptions = {
requestedPath?: string;
allowedRoot?: string;
};
async function realDirectory(path: string, label: string): Promise<string> {
let canonicalPath: string;
try {
canonicalPath = await realpath(path);
} catch (error) {
throw new CodexRuntimeError(
"INVALID_WORKSPACE",
`${label} does not exist or cannot be resolved: ${path}`,
error,
);
}
const info = await stat(canonicalPath);
if (!info.isDirectory()) {
throw new CodexRuntimeError("INVALID_WORKSPACE", `${label} must be a directory: ${path}`);
}
return canonicalPath;
}
function isWithinRoot(candidate: string, root: string): boolean {
const child = relative(root, candidate);
return child === "" || (!child.startsWith("..") && !isAbsolute(child));
}
/**
* Resolve a user-selected workspace to a canonical server-side directory.
*
* V0 intentionally confines workspaces to CODEX_WORKSPACE_ROOT (or process.cwd())
* so a browser request cannot point Codex at arbitrary host paths.
*/
export async function resolveWorkspace({
requestedPath,
allowedRoot = process.env.CODEX_WORKSPACE_ROOT ?? process.cwd(),
}: ResolveWorkspaceOptions = {}): Promise<string> {
const canonicalRoot = await realDirectory(resolve(allowedRoot), "Codex workspace root");
const rawCandidate = requestedPath?.trim() || canonicalRoot;
const candidatePath = isAbsolute(rawCandidate)
? rawCandidate
: resolve(canonicalRoot, rawCandidate);
const canonicalCandidate = await realDirectory(candidatePath, "Codex workspace");
if (!isWithinRoot(canonicalCandidate, canonicalRoot)) {
throw new CodexRuntimeError(
"INVALID_WORKSPACE",
`Workspace must stay within the configured Codex workspace root: ${canonicalRoot}`,
);
}
return canonicalCandidate;
}
+46
View File
@@ -0,0 +1,46 @@
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveWorkspace } from "./workspace.server";
describe("resolveWorkspace", () => {
it("resolves a relative workspace inside the allowed root", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-runtime-"));
await mkdir(join(root, "repo"));
await expect(resolveWorkspace({ allowedRoot: root, requestedPath: "repo" })).resolves.toBe(
join(root, "repo"),
);
});
it("rejects paths outside the allowed root", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-runtime-root-"));
const outside = await mkdtemp(join(tmpdir(), "codex-runtime-outside-"));
await expect(resolveWorkspace({ allowedRoot: root, requestedPath: outside })).rejects.toMatchObject({
code: "INVALID_WORKSPACE",
});
});
it("rejects symlinks that escape the allowed root", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-runtime-root-"));
const outside = await mkdtemp(join(tmpdir(), "codex-runtime-outside-"));
await symlink(outside, join(root, "escape"));
await expect(resolveWorkspace({ allowedRoot: root, requestedPath: "escape" })).rejects.toMatchObject({
code: "INVALID_WORKSPACE",
});
});
it("rejects files as workspaces", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-runtime-root-"));
await writeFile(join(root, "file.txt"), "not a directory");
await expect(resolveWorkspace({ allowedRoot: root, requestedPath: "file.txt" })).rejects.toMatchObject({
code: "INVALID_WORKSPACE",
});
});
});
+229
View File
@@ -0,0 +1,229 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eef2f7;
background: #090b0f;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body, #root { min-height: 100%; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #090b0f; }
button, textarea, input { font: inherit; }
button { color: inherit; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.chat-shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
background:
radial-gradient(circle at 50% -20%, rgba(76, 110, 245, .08), transparent 32rem),
#090b0f;
}
.topbar {
width: min(1120px, calc(100% - 40px));
margin: 0 auto;
min-height: 86px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid #1c2129;
}
.brand-row { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
background: #eef2f7;
color: #101319;
font-size: 14px;
font-weight: 800;
}
.topbar h1 { margin: 0; font-size: 16px; font-weight: 650; letter-spacing: -.01em; }
.topbar p { margin: 5px 0 0 40px; color: #778190; font-size: 12px; }
.secondary-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 36px;
padding: 0 13px;
border: 1px solid #2a303a;
border-radius: 9px;
background: #11151b;
color: #cfd6df;
cursor: pointer;
font-size: 13px;
font-weight: 550;
}
.secondary-button:hover { background: #171c24; border-color: #39414e; }
.secondary-button:focus-visible, .send-button:focus-visible, .composer__input:focus-visible {
outline: 2px solid #7ca8ff;
outline-offset: 2px;
}
.chat-panel {
width: min(860px, calc(100% - 40px));
height: calc(100vh - 86px);
margin: 0 auto;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
}
.conversation { overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.conversation__inner { max-width: 760px; margin: 0 auto; padding: 46px 0 32px; }
.message { margin-bottom: 34px; }
.message__meta { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; color: #bfc7d2; font-size: 13px; font-weight: 600; }
.avatar { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 7px; font-size: 11px; font-weight: 800; }
.avatar--user { background: #252b34; color: #dce2ea; }
.avatar--assistant { background: #dde5ef; color: #11151a; }
.message__content {
margin-left: 33px;
color: #d9dfe7;
font-size: 15px;
line-height: 1.72;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message--user .message__content { color: #f2f5f8; }
.running-indicator {
margin: -15px 0 24px 33px;
display: flex;
align-items: center;
gap: 8px;
color: #8b96a5;
font-size: 12px;
}
.running-indicator__dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #7ca8ff;
box-shadow: 0 0 0 4px rgba(124, 168, 255, .08);
animation: pulse 1.35s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .35; transform: scale(.78); } }
@media (prefers-reduced-motion: reduce) { .running-indicator__dot { animation: none; } }
.activity {
margin: 0 0 32px 33px;
border: 1px solid #242a33;
border-radius: 12px;
background: #0e1217;
overflow: hidden;
}
.activity__header {
min-height: 39px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #202630;
color: #aeb7c3;
font-size: 12px;
font-weight: 600;
}
.activity__count { min-width: 20px; padding: 2px 6px; border-radius: 999px; background: #202630; color: #8f99a7; text-align: center; font-size: 10px; }
.activity__list { list-style: none; margin: 0; padding: 7px 0; }
.activity__item { display: grid; grid-template-columns: 10px 1fr; gap: 10px; padding: 8px 13px; }
.activity__status { width: 7px; height: 7px; margin-top: 5px; border-radius: 999px; background: #67717f; }
.activity__status--running { background: #7ca8ff; box-shadow: 0 0 0 3px rgba(124, 168, 255, .08); }
.activity__status--completed { background: #77bf97; }
.activity__status--failed { background: #e17f86; }
.activity__body { min-width: 0; }
.activity__title-row { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.activity__kind { flex: 0 0 auto; color: #697482; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; }
.activity__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #bdc5cf; font-size: 12px; }
.activity__body p { margin: 4px 0 0; color: #717c8a; font-size: 11px; line-height: 1.5; }
.error-banner {
margin: 0 0 28px 33px;
display: grid;
gap: 4px;
padding: 12px 14px;
border: 1px solid rgba(220, 98, 105, .35);
border-radius: 10px;
background: rgba(112, 35, 40, .12);
color: #e8b5b8;
font-size: 12px;
}
.error-banner strong { color: #f0c3c6; }
.composer-wrap {
padding: 14px 0 26px;
background: linear-gradient(to bottom, rgba(9,11,15,0), #090b0f 24%);
}
.composer {
position: relative;
max-width: 760px;
margin: 0 auto;
border: 1px solid #2a3039;
border-radius: 15px;
background: #11151b;
box-shadow: 0 18px 50px rgba(0,0,0,.18);
}
.composer:focus-within { border-color: #414b5a; box-shadow: 0 0 0 3px rgba(124, 168, 255, .05), 0 18px 50px rgba(0,0,0,.18); }
.composer__input {
width: 100%;
min-height: 72px;
max-height: 180px;
resize: vertical;
display: block;
padding: 17px 54px 30px 17px;
border: 0;
outline: 0;
background: transparent;
color: #edf1f6;
caret-color: #9abaff;
font-size: 14px;
line-height: 1.55;
}
.composer__input::placeholder { color: #626d7b; }
.composer__input:disabled { cursor: not-allowed; opacity: .58; }
.composer__hint { position: absolute; left: 17px; bottom: 9px; color: #5d6876; font-size: 10px; pointer-events: none; }
.send-button {
position: absolute;
right: 11px;
bottom: 11px;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
background: #e5ebf3;
color: #10141a;
cursor: pointer;
}
.send-button:hover:not(:disabled) { background: #fff; }
.send-button:disabled { background: #252c35; color: #596472; cursor: not-allowed; }
@media (max-width: 640px) {
.topbar { width: calc(100% - 28px); min-height: 76px; }
.topbar p { display: none; }
.secondary-button { padding: 0 10px; }
.chat-panel { width: calc(100% - 28px); height: calc(100vh - 76px); }
.conversation__inner { padding-top: 30px; }
.message__content, .running-indicator, .activity, .error-banner { margin-left: 0; }
.composer-wrap { padding-bottom: 14px; }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
export default defineConfig({
server: { port: 3000 },
plugins: [tanstackStart(), react()],
})