feat: add Codex TanStack Start demo
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.output
|
||||
.tanstack
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()],
|
||||
})
|
||||
Reference in New Issue
Block a user