diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..423f9cc --- /dev/null +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..deed237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +.output +.tanstack +.env +.env.* +!.env.example +coverage +*.log +.DS_Store diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3983580 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md new file mode 100644 index 0000000..ed175af --- /dev/null +++ b/docs/DEVELOPMENT_PLAN.md @@ -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. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..3294eca --- /dev/null +++ b/docs/INTEGRATION.md @@ -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. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..a4b9414 --- /dev/null +++ b/docs/VALIDATION.md @@ -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. diff --git a/docs/tasks/01-codex-runtime.md b/docs/tasks/01-codex-runtime.md new file mode 100644 index 0000000..f54468a --- /dev/null +++ b/docs/tasks/01-codex-runtime.md @@ -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. diff --git a/docs/tasks/02-integration-notes.md b/docs/tasks/02-integration-notes.md new file mode 100644 index 0000000..7637547 --- /dev/null +++ b/docs/tasks/02-integration-notes.md @@ -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 +``` + +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. diff --git a/docs/tasks/02-streaming-bridge.md b/docs/tasks/02-streaming-bridge.md new file mode 100644 index 0000000..287ae12 --- /dev/null +++ b/docs/tasks/02-streaming-bridge.md @@ -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. diff --git a/docs/tasks/03-chat-ui.md b/docs/tasks/03-chat-ui.md new file mode 100644 index 0000000..7ef6693 --- /dev/null +++ b/docs/tasks/03-chat-ui.md @@ -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`. diff --git a/docs/tasks/04-state-persistence-tests.md b/docs/tasks/04-state-persistence-tests.md new file mode 100644 index 0000000..1ac28b8 --- /dev/null +++ b/docs/tasks/04-state-persistence-tests.md @@ -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. diff --git a/docs/tasks/05-integration.md b/docs/tasks/05-integration.md new file mode 100644 index 0000000..4b21a0e --- /dev/null +++ b/docs/tasks/05-integration.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1869210 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/features/chat/chat-event.adapter.test.ts b/src/features/chat/chat-event.adapter.test.ts new file mode 100644 index 0000000..ef2ee5a --- /dev/null +++ b/src/features/chat/chat-event.adapter.test.ts @@ -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', + }, + }) + }) +}) diff --git a/src/features/chat/chat-event.adapter.ts b/src/features/chat/chat-event.adapter.ts new file mode 100644 index 0000000..51a20d3 --- /dev/null +++ b/src/features/chat/chat-event.adapter.ts @@ -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 + } +} diff --git a/src/features/chat/chat.reducer.test.ts b/src/features/chat/chat.reducer.test.ts new file mode 100644 index 0000000..172d970 --- /dev/null +++ b/src/features/chat/chat.reducer.test.ts @@ -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') + }) +}) diff --git a/src/features/chat/chat.reducer.ts b/src/features/chat/chat.reducer.ts new file mode 100644 index 0000000..5869f0c --- /dev/null +++ b/src/features/chat/chat.reducer.ts @@ -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> & Pick + } + | { 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 + } +} diff --git a/src/features/chat/chat.storage.test.ts b/src/features/chat/chat.storage.test.ts new file mode 100644 index 0000000..bffd75b --- /dev/null +++ b/src/features/chat/chat.storage.test.ts @@ -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() + + 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) + }) +}) diff --git a/src/features/chat/chat.storage.ts b/src/features/chat/chat.storage.ts new file mode 100644 index 0000000..94c0f9b --- /dev/null +++ b/src/features/chat/chat.storage.ts @@ -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 { + 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 + } +} diff --git a/src/features/chat/chat.types.ts b/src/features/chat/chat.types.ts new file mode 100644 index 0000000..ab7e773 --- /dev/null +++ b/src/features/chat/chat.types.ts @@ -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 + } diff --git a/src/features/chat/components/agent-activity.tsx b/src/features/chat/components/agent-activity.tsx new file mode 100644 index 0000000..12a51de --- /dev/null +++ b/src/features/chat/components/agent-activity.tsx @@ -0,0 +1,46 @@ +import type { AgentActivity as AgentActivityModel } from '../chat.reducer' + +interface AgentActivityProps { + activities: AgentActivityModel[] +} + +const kindLabel: Record = { + 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 ( + + ) +} diff --git a/src/features/chat/components/chat-input.tsx b/src/features/chat/components/chat-input.tsx new file mode 100644 index 0000000..85b2602 --- /dev/null +++ b/src/features/chat/components/chat-input.tsx @@ -0,0 +1,68 @@ +import { useId, useRef, useState, type FormEvent, type KeyboardEvent } from 'react' + +interface ChatInputProps { + disabled?: boolean + onSubmit: (value: string) => void | Promise +} + +export function ChatInput({ disabled = false, onSubmit }: ChatInputProps) { + const [value, setValue] = useState('') + const textareaRef = useRef(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) { + event.preventDefault() + void submit() + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault() + void submit() + } + } + + return ( +
+ +