@codexview/react 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3903 +0,0 @@
1
- # CodexView Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Build `codexview`, a standalone React component library that renders agentweb's `ChatStreamEvent` stream into a chat-style transcript with status animations, ready for agentweb integration.
6
-
7
- **Architecture:** Pure React 18 component library, ESM-only, CSS Modules + CSS variables for theming. Internal `useReducer`-based state, no external state library. `(events, status) → (model, derivedStatus) → JSX`. Two-level state machine (turn + item), 8 item kinds + raw fallback. Reasoning lives in its own block; same-turn assistant items share a left timeline axis.
8
-
9
- **Tech Stack:** React 18.3, TypeScript 5.5 (strict + noUncheckedIndexAccess), tsup (esbuild), vitest + @testing-library/react + jsdom, lucide-react (peerDep), CSS Modules, vite (dev/ SPA only).
10
-
11
- **Spec:** [docs/superpowers/specs/2026-05-15-codexview-design.md](../specs/2026-05-15-codexview-design.md)
12
-
13
- ---
14
-
15
- ## File structure overview
16
-
17
- ```
18
- CodexView/
19
- ├── .gitignore, .npmrc, .editorconfig, .nvmrc
20
- ├── package.json, tsconfig.json, tsup.config.ts, vitest.config.ts
21
- ├── README.md
22
- ├── docs/{api,events,styling,integration-agentweb,changelog}.md
23
- ├── src/
24
- │ ├── index.ts # public re-exports only
25
- │ ├── types/{events,model,theme}.ts
26
- │ ├── reducer/{transcript,status}.ts + .test.ts
27
- │ ├── hooks/{useCodexTranscript,useSmoothStream}.ts + .test.ts
28
- │ ├── components/
29
- │ │ ├── CodexTranscript.tsx + .module.css
30
- │ │ ├── StatusBar.tsx + .module.css
31
- │ │ ├── TurnContainer.tsx + .module.css
32
- │ │ ├── MessageBubble.tsx + .module.css
33
- │ │ ├── ReasoningBlock.tsx + .module.css
34
- │ │ ├── ToolCallBlock.tsx + .module.css
35
- │ │ ├── ExecBlock.tsx + .module.css
36
- │ │ ├── SearchBlock.tsx + .module.css
37
- │ │ ├── PatchBlock.tsx + .module.css
38
- │ │ ├── RawEventBlock.tsx + .module.css
39
- │ │ ├── ItemErrorBoundary.tsx
40
- │ │ └── icons.ts
41
- │ ├── styles/{reset.module.css, tokens.css}
42
- │ └── integration/replay.test.tsx
43
- ├── fixtures/
44
- │ ├── README.md
45
- │ ├── short-chat.jsonl
46
- │ ├── tool-heavy.jsonl
47
- │ ├── mcp-flow.jsonl
48
- │ ├── failed-turn.jsonl
49
- │ ├── aborted-turn.jsonl
50
- │ └── unknown-types.jsonl
51
- └── dev/
52
- ├── index.html, vite.config.ts
53
- └── src/{main.tsx, App.tsx}
54
- ```
55
-
56
- ---
57
-
58
- ## Task 1: Repo scaffolding (git, package.json, tsconfig)
59
-
60
- **Files:**
61
- - Create: `.gitignore`, `.editorconfig`, `.nvmrc`, `.npmrc`, `package.json`, `tsconfig.json`, `tsconfig.dev.json`, `README.md` (placeholder)
62
-
63
- - [ ] **Step 1: git init**
64
-
65
- ```bash
66
- cd /Volumes/MaxSSD1/MigratedHome/maxazure/projects/CodexView
67
- git init
68
- git config core.autocrlf input
69
- ```
70
-
71
- Expected: `Initialized empty Git repository`.
72
-
73
- - [ ] **Step 2: Write `.gitignore`**
74
-
75
- ```
76
- node_modules/
77
- dist/
78
- .DS_Store
79
- *.log
80
- .vscode/
81
- .idea/
82
- coverage/
83
- .tsbuildinfo
84
- *.tgz
85
- ```
86
-
87
- - [ ] **Step 3: Write `.editorconfig`**
88
-
89
- ```
90
- root = true
91
-
92
- [*]
93
- charset = utf-8
94
- end_of_line = lf
95
- indent_style = space
96
- indent_size = 2
97
- insert_final_newline = true
98
- trim_trailing_whitespace = true
99
- ```
100
-
101
- - [ ] **Step 4: Write `.nvmrc`**
102
-
103
- ```
104
- 20
105
- ```
106
-
107
- - [ ] **Step 5: Write `.npmrc`**
108
-
109
- ```
110
- strict-peer-dependencies=false
111
- auto-install-peers=true
112
- ```
113
-
114
- - [ ] **Step 6: Write `package.json`**
115
-
116
- ```json
117
- {
118
- "name": "codexview",
119
- "version": "0.1.0",
120
- "description": "React components for rendering OpenAI Codex CLI chat streams.",
121
- "type": "module",
122
- "license": "MIT",
123
- "exports": {
124
- ".": {
125
- "types": "./dist/index.d.ts",
126
- "import": "./dist/index.js"
127
- },
128
- "./styles.css": "./dist/styles.css"
129
- },
130
- "main": "./dist/index.js",
131
- "types": "./dist/index.d.ts",
132
- "files": ["dist", "README.md", "docs"],
133
- "sideEffects": ["**/*.css"],
134
- "scripts": {
135
- "build": "tsup",
136
- "dev": "vite --config dev/vite.config.ts",
137
- "test": "vitest run",
138
- "test:watch": "vitest",
139
- "typecheck": "tsc -p tsconfig.json --noEmit",
140
- "prepublishOnly": "pnpm build"
141
- },
142
- "peerDependencies": {
143
- "react": "^18.3.0",
144
- "react-dom": "^18.3.0",
145
- "lucide-react": "^0.400.0"
146
- },
147
- "devDependencies": {
148
- "@testing-library/jest-dom": "^6.4.6",
149
- "@testing-library/react": "^16.0.0",
150
- "@testing-library/user-event": "^14.5.2",
151
- "@types/node": "^20.14.0",
152
- "@types/react": "^18.3.3",
153
- "@types/react-dom": "^18.3.0",
154
- "@vitejs/plugin-react": "^4.3.1",
155
- "jsdom": "^25.0.0",
156
- "lucide-react": "^0.400.0",
157
- "react": "^18.3.1",
158
- "react-dom": "^18.3.1",
159
- "tsup": "^8.1.0",
160
- "typescript": "^5.5.4",
161
- "vite": "^5.4.0",
162
- "vitest": "^2.0.0"
163
- },
164
- "engines": { "node": ">=20" }
165
- }
166
- ```
167
-
168
- - [ ] **Step 7: Write `tsconfig.json`** (production / lib)
169
-
170
- ```json
171
- {
172
- "compilerOptions": {
173
- "target": "ES2022",
174
- "module": "ESNext",
175
- "moduleResolution": "Bundler",
176
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
177
- "jsx": "react-jsx",
178
- "strict": true,
179
- "noUncheckedIndexedAccess": true,
180
- "noImplicitOverride": true,
181
- "noFallthroughCasesInSwitch": true,
182
- "exactOptionalPropertyTypes": true,
183
- "useUnknownInCatchVariables": true,
184
- "isolatedModules": true,
185
- "verbatimModuleSyntax": true,
186
- "esModuleInterop": true,
187
- "skipLibCheck": true,
188
- "resolveJsonModule": true,
189
- "allowImportingTsExtensions": false,
190
- "noEmit": true,
191
- "types": ["node", "vitest/globals"]
192
- },
193
- "include": ["src/**/*", "dev/**/*"],
194
- "exclude": ["dist", "node_modules"]
195
- }
196
- ```
197
-
198
- - [ ] **Step 8: Write `tsconfig.dev.json`** (dev SPA, allows JSX in .tsx test setups)
199
-
200
- ```json
201
- {
202
- "extends": "./tsconfig.json",
203
- "compilerOptions": {
204
- "noEmit": false,
205
- "outDir": "dev-build"
206
- },
207
- "include": ["dev/**/*", "src/**/*"]
208
- }
209
- ```
210
-
211
- - [ ] **Step 9: Write a placeholder `README.md`**
212
-
213
- ```markdown
214
- # codexview
215
-
216
- React components for rendering OpenAI Codex CLI chat streams.
217
-
218
- > Status: 0.1.0-pre — see [docs/superpowers/plans/2026-05-15-codexview-implementation.md](docs/superpowers/plans/2026-05-15-codexview-implementation.md) for build progress.
219
-
220
- Documentation will land in [docs/](docs/) once features are implemented.
221
- ```
222
-
223
- - [ ] **Step 10: Install dependencies**
224
-
225
- ```bash
226
- pnpm install
227
- ```
228
-
229
- Expected: dependencies installed in `node_modules/`, `pnpm-lock.yaml` created.
230
-
231
- - [ ] **Step 11: Verify typecheck passes (no source files yet, so noop)**
232
-
233
- ```bash
234
- mkdir -p src && echo "export {};" > src/index.ts
235
- pnpm typecheck
236
- ```
237
-
238
- Expected: exit 0.
239
-
240
- - [ ] **Step 12: First commit**
241
-
242
- ```bash
243
- git add -A
244
- git commit -m "chore: initial scaffolding (package.json, tsconfig, lockfile)"
245
- ```
246
-
247
- ---
248
-
249
- ## Task 2: tsup build + vitest config + src/index.ts placeholder
250
-
251
- **Files:**
252
- - Create: `tsup.config.ts`, `vitest.config.ts`, `src/test-setup.ts`, `src/index.ts`
253
-
254
- - [ ] **Step 1: Write `tsup.config.ts`**
255
-
256
- ```ts
257
- import { defineConfig } from 'tsup';
258
-
259
- export default defineConfig({
260
- entry: ['src/index.ts'],
261
- format: ['esm'],
262
- dts: true,
263
- sourcemap: true,
264
- clean: true,
265
- target: 'es2022',
266
- external: ['react', 'react-dom', 'lucide-react'],
267
- injectStyle: false,
268
- loader: { '.css': 'copy' },
269
- esbuildOptions(options) {
270
- options.assetNames = 'styles';
271
- },
272
- });
273
- ```
274
-
275
- - [ ] **Step 2: Write `vitest.config.ts`**
276
-
277
- ```ts
278
- import { defineConfig } from 'vitest/config';
279
- import react from '@vitejs/plugin-react';
280
-
281
- export default defineConfig({
282
- plugins: [react()],
283
- test: {
284
- environment: 'jsdom',
285
- globals: true,
286
- setupFiles: ['src/test-setup.ts'],
287
- css: { modules: { classNameStrategy: 'non-scoped' } },
288
- coverage: {
289
- provider: 'v8',
290
- include: ['src/**/*.{ts,tsx}'],
291
- exclude: ['src/**/*.test.{ts,tsx}', 'src/test-setup.ts', 'src/index.ts'],
292
- },
293
- },
294
- });
295
- ```
296
-
297
- - [ ] **Step 3: Write `src/test-setup.ts`**
298
-
299
- ```ts
300
- import '@testing-library/jest-dom/vitest';
301
- ```
302
-
303
- - [ ] **Step 4: Replace `src/index.ts` with placeholder**
304
-
305
- ```ts
306
- export const VERSION = '0.1.0';
307
- ```
308
-
309
- - [ ] **Step 5: Run typecheck + tests + build**
310
-
311
- ```bash
312
- pnpm typecheck && pnpm test && pnpm build
313
- ```
314
-
315
- Expected: typecheck PASS, vitest "No test files found" exit 0, tsup creates `dist/index.js` + `dist/index.d.ts`.
316
-
317
- - [ ] **Step 6: Commit**
318
-
319
- ```bash
320
- git add -A
321
- git commit -m "chore: tsup + vitest configuration"
322
- ```
323
-
324
- ---
325
-
326
- ## Task 3: Define `ChatStreamEvent` types
327
-
328
- **Files:**
329
- - Create: `src/types/events.ts`
330
-
331
- - [ ] **Step 1: Write `src/types/events.ts`**
332
-
333
- ```ts
334
- /**
335
- * Token usage emitted by `turn_completed`.
336
- */
337
- export interface TokenUsage {
338
- inputTokens: number;
339
- outputTokens: number;
340
- cachedInputTokens?: number;
341
- reasoningOutputTokens?: number;
342
- }
343
-
344
- /** Result of a single web search hit. */
345
- export interface SearchResult {
346
- title: string;
347
- url: string;
348
- snippet?: string;
349
- }
350
-
351
- /** Single file in a patch_apply result. */
352
- export interface PatchFile {
353
- path: string;
354
- status: 'added' | 'modified' | 'deleted';
355
- /** Unified git diff text. Optional because some events only carry metadata. */
356
- diff?: string;
357
- }
358
-
359
- /**
360
- * The discriminated union of events CodexView consumes.
361
- *
362
- * Source of truth: agentweb `backend/src/codex/eventMap.ts` `NormalizedEvent`.
363
- * This file is the contract boundary — it intentionally re-declares the shapes
364
- * so that consumers don't have to depend on agentweb internals.
365
- */
366
- export type ChatStreamEvent =
367
- // Lifecycle
368
- | { type: 'thread_started'; threadId: string; at: number }
369
- | { type: 'turn_started'; turnId: string; at: number }
370
- | { type: 'turn_completed'; turnId: string; at: number; usage?: TokenUsage }
371
- | { type: 'turn_failed'; turnId: string; at: number; error: { message: string; code?: string } }
372
- | { type: 'turn_aborted'; turnId: string; at: number; reason?: string }
373
-
374
- // Messages
375
- | { type: 'user_message'; turnId: string; itemId: string; text: string; at: number }
376
- | { type: 'agent_message'; turnId: string; itemId: string; text: string; partial: boolean; at: number }
377
- | { type: 'reasoning'; turnId: string; itemId: string; text: string; partial: boolean; at: number }
378
-
379
- // Tool calls (paired by callId)
380
- | { type: 'function_call'; turnId: string; callId: string; name: string; args: unknown; at: number }
381
- | { type: 'function_call_output'; turnId: string; callId: string; output?: unknown; error?: string; at: number }
382
-
383
- // Shell exec
384
- | { type: 'exec_command_begin'; turnId: string; callId: string; command: string; at: number }
385
- | { type: 'exec_command_end'; turnId: string; callId: string; exit: number; stdout: string; stderr: string; durationMs: number; at: number }
386
-
387
- // MCP tool calls
388
- | { type: 'mcp_tool_call'; turnId: string; callId: string; server: string; name: string; args: unknown; at: number }
389
- | { type: 'mcp_tool_call_output'; turnId: string; callId: string; output?: unknown; error?: string; at: number }
390
-
391
- // Web search
392
- | { type: 'web_search_call'; turnId: string; callId: string; query: string; at: number }
393
- | { type: 'web_search_end'; turnId: string; callId: string; results: SearchResult[]; at: number }
394
-
395
- // Patch apply
396
- | { type: 'patch_apply_end'; turnId: string; callId: string; files: PatchFile[]; ok: boolean; at: number }
397
-
398
- // Fallback
399
- | { type: 'raw'; turnId?: string; itemId?: string; payload: unknown; at: number };
400
-
401
- /** Helpful narrowing alias. */
402
- export type ChatStreamEventType = ChatStreamEvent['type'];
403
- ```
404
-
405
- - [ ] **Step 2: Verify typecheck**
406
-
407
- ```bash
408
- pnpm typecheck
409
- ```
410
-
411
- Expected: exit 0.
412
-
413
- - [ ] **Step 3: Commit**
414
-
415
- ```bash
416
- git add src/types/events.ts
417
- git commit -m "feat(types): ChatStreamEvent contract"
418
- ```
419
-
420
- ---
421
-
422
- ## Task 4: Define view model types + EMPTY_MODEL
423
-
424
- **Files:**
425
- - Create: `src/types/model.ts`
426
-
427
- - [ ] **Step 1: Write `src/types/model.ts`**
428
-
429
- ```ts
430
- import type { PatchFile, SearchResult, TokenUsage } from './events.js';
431
-
432
- /** Item-level lifecycle status (5 states per spec §5.1). */
433
- export type ItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
434
-
435
- /** Discriminated kind of a rendered item. */
436
- export type ItemKind =
437
- | 'user_message'
438
- | 'reasoning'
439
- | 'assistant_text'
440
- | 'tool_call'
441
- | 'exec'
442
- | 'search'
443
- | 'patch'
444
- | 'raw';
445
-
446
- interface ItemViewBase {
447
- id: string;
448
- kind: ItemKind;
449
- status: ItemStatus;
450
- startedAt: number;
451
- updatedAt: number;
452
- }
453
-
454
- export type ItemView =
455
- | (ItemViewBase & { kind: 'user_message'; text: string })
456
- | (ItemViewBase & { kind: 'reasoning'; text: string })
457
- | (ItemViewBase & { kind: 'assistant_text'; text: string })
458
- | (ItemViewBase & { kind: 'tool_call'; name: string; server?: string; args: unknown; result?: unknown; error?: string })
459
- | (ItemViewBase & { kind: 'exec'; command: string; exit?: number; stdout?: string; stderr?: string; durationMs?: number })
460
- | (ItemViewBase & { kind: 'search'; query: string; results?: SearchResult[] })
461
- | (ItemViewBase & { kind: 'patch'; files: PatchFile[]; ok?: boolean })
462
- | (ItemViewBase & { kind: 'raw'; payload: unknown });
463
-
464
- export interface TurnView {
465
- turnId: string;
466
- startedAt: number;
467
- completedAt?: number;
468
- status: 'running' | 'completed' | 'failed' | 'aborted';
469
- items: ItemView[];
470
- usage?: TokenUsage;
471
- error?: { message: string; code?: string };
472
- }
473
-
474
- export interface TranscriptModel {
475
- threadId?: string;
476
- turns: TurnView[];
477
- lastEventAt: number;
478
- }
479
-
480
- /** Empty initial model. Always safe to start reducing from here. */
481
- export const EMPTY_MODEL: TranscriptModel = Object.freeze({
482
- turns: [],
483
- lastEventAt: 0,
484
- }) as TranscriptModel;
485
-
486
- /** Session-level status (5 states per spec §5.3). */
487
- export type TranscriptStatus = 'idle' | 'working' | 'completed' | 'stopped' | 'failed';
488
- ```
489
-
490
- - [ ] **Step 2: Verify typecheck**
491
-
492
- ```bash
493
- pnpm typecheck
494
- ```
495
-
496
- Expected: exit 0.
497
-
498
- - [ ] **Step 3: Commit**
499
-
500
- ```bash
501
- git add src/types/model.ts
502
- git commit -m "feat(types): TranscriptModel / TurnView / ItemView"
503
- ```
504
-
505
- ---
506
-
507
- ## Task 5: reducer skeleton + lifecycle events (TDD)
508
-
509
- **Files:**
510
- - Create: `src/reducer/transcript.ts`, `src/reducer/transcript.test.ts`
511
-
512
- - [ ] **Step 1: Write the failing test for thread_started**
513
-
514
- `src/reducer/transcript.test.ts`:
515
-
516
- ```ts
517
- import { describe, expect, it } from 'vitest';
518
- import { EMPTY_MODEL } from '../types/model.js';
519
- import { reduceTranscript } from './transcript.js';
520
-
521
- describe('reduceTranscript / lifecycle', () => {
522
- it('thread_started sets threadId', () => {
523
- const next = reduceTranscript(EMPTY_MODEL, {
524
- type: 'thread_started',
525
- threadId: 't-1',
526
- at: 100,
527
- });
528
- expect(next.threadId).toBe('t-1');
529
- expect(next.turns).toEqual([]);
530
- expect(next.lastEventAt).toBe(100);
531
- });
532
-
533
- it('turn_started appends a running turn', () => {
534
- const next = reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 200 });
535
- expect(next.turns).toHaveLength(1);
536
- expect(next.turns[0]).toMatchObject({ turnId: 'tn-1', status: 'running', startedAt: 200, items: [] });
537
- });
538
-
539
- it('turn_completed marks turn completed and writes usage', () => {
540
- const m1 = reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 200 });
541
- const m2 = reduceTranscript(m1, {
542
- type: 'turn_completed',
543
- turnId: 'tn-1',
544
- at: 300,
545
- usage: { inputTokens: 10, outputTokens: 20 },
546
- });
547
- expect(m2.turns[0]?.status).toBe('completed');
548
- expect(m2.turns[0]?.completedAt).toBe(300);
549
- expect(m2.turns[0]?.usage).toEqual({ inputTokens: 10, outputTokens: 20 });
550
- });
551
-
552
- it('turn_failed marks turn failed and stores error', () => {
553
- const m1 = reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 200 });
554
- const m2 = reduceTranscript(m1, {
555
- type: 'turn_failed',
556
- turnId: 'tn-1',
557
- at: 300,
558
- error: { message: 'boom', code: 'E1' },
559
- });
560
- expect(m2.turns[0]?.status).toBe('failed');
561
- expect(m2.turns[0]?.error).toEqual({ message: 'boom', code: 'E1' });
562
- });
563
-
564
- it('turn_aborted marks turn aborted', () => {
565
- const m1 = reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 200 });
566
- const m2 = reduceTranscript(m1, { type: 'turn_aborted', turnId: 'tn-1', at: 300 });
567
- expect(m2.turns[0]?.status).toBe('aborted');
568
- });
569
-
570
- it('reducer is pure: input model is not mutated', () => {
571
- const before = JSON.stringify(EMPTY_MODEL);
572
- reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 1 });
573
- expect(JSON.stringify(EMPTY_MODEL)).toBe(before);
574
- });
575
- });
576
- ```
577
-
578
- - [ ] **Step 2: Run the test to confirm it fails**
579
-
580
- ```bash
581
- pnpm test -- src/reducer/transcript.test.ts
582
- ```
583
-
584
- Expected: FAIL with "Cannot find module './transcript.js'".
585
-
586
- - [ ] **Step 3: Write minimal `src/reducer/transcript.ts` covering lifecycle only**
587
-
588
- ```ts
589
- import type { ChatStreamEvent } from '../types/events.js';
590
- import type { ItemStatus, ItemView, TranscriptModel, TurnView } from '../types/model.js';
591
- import { EMPTY_MODEL } from '../types/model.js';
592
-
593
- function findTurnIndex(model: TranscriptModel, turnId: string): number {
594
- for (let i = model.turns.length - 1; i >= 0; i -= 1) {
595
- if (model.turns[i]!.turnId === turnId) return i;
596
- }
597
- return -1;
598
- }
599
-
600
- function replaceTurn(model: TranscriptModel, index: number, turn: TurnView, at: number): TranscriptModel {
601
- const turns = model.turns.slice();
602
- turns[index] = turn;
603
- return { ...model, turns, lastEventAt: at };
604
- }
605
-
606
- function flipUnfinished(items: ItemView[], next: ItemStatus): ItemView[] {
607
- return items.map((item) =>
608
- item.status === 'pending' || item.status === 'running' ? { ...item, status: next, updatedAt: item.updatedAt } : item,
609
- );
610
- }
611
-
612
- export function reduceTranscript(prev: TranscriptModel, event: ChatStreamEvent): TranscriptModel {
613
- switch (event.type) {
614
- case 'thread_started':
615
- return { ...prev, threadId: event.threadId, lastEventAt: event.at };
616
-
617
- case 'turn_started': {
618
- const turn: TurnView = {
619
- turnId: event.turnId,
620
- startedAt: event.at,
621
- status: 'running',
622
- items: [],
623
- };
624
- return { ...prev, turns: [...prev.turns, turn], lastEventAt: event.at };
625
- }
626
-
627
- case 'turn_completed': {
628
- const i = findTurnIndex(prev, event.turnId);
629
- if (i < 0) return { ...prev, lastEventAt: event.at };
630
- const t = prev.turns[i]!;
631
- const turn: TurnView = {
632
- ...t,
633
- status: 'completed',
634
- completedAt: event.at,
635
- items: flipUnfinished(t.items, 'completed'),
636
- };
637
- if (event.usage !== undefined) turn.usage = event.usage;
638
- return replaceTurn(prev, i, turn, event.at);
639
- }
640
-
641
- case 'turn_failed': {
642
- const i = findTurnIndex(prev, event.turnId);
643
- if (i < 0) return { ...prev, lastEventAt: event.at };
644
- const t = prev.turns[i]!;
645
- return replaceTurn(prev, i, {
646
- ...t,
647
- status: 'failed',
648
- completedAt: event.at,
649
- error: event.error,
650
- items: flipUnfinished(t.items, 'failed'),
651
- }, event.at);
652
- }
653
-
654
- case 'turn_aborted': {
655
- const i = findTurnIndex(prev, event.turnId);
656
- if (i < 0) return { ...prev, lastEventAt: event.at };
657
- const t = prev.turns[i]!;
658
- return replaceTurn(prev, i, {
659
- ...t,
660
- status: 'aborted',
661
- completedAt: event.at,
662
- items: flipUnfinished(t.items, 'stopped'),
663
- }, event.at);
664
- }
665
-
666
- default:
667
- return { ...prev, lastEventAt: event.at };
668
- }
669
- }
670
-
671
- export { EMPTY_MODEL };
672
- ```
673
-
674
- - [ ] **Step 4: Run test to verify pass**
675
-
676
- ```bash
677
- pnpm test -- src/reducer/transcript.test.ts
678
- ```
679
-
680
- Expected: 6 tests PASS.
681
-
682
- - [ ] **Step 5: Commit**
683
-
684
- ```bash
685
- git add src/reducer/
686
- git commit -m "feat(reducer): lifecycle events (thread/turn started/completed/failed/aborted)"
687
- ```
688
-
689
- ---
690
-
691
- ## Task 6: reducer + messages (user/agent/reasoning)
692
-
693
- **Files:**
694
- - Modify: `src/reducer/transcript.ts`, `src/reducer/transcript.test.ts`
695
-
696
- - [ ] **Step 1: Add failing tests for messages**
697
-
698
- Append to `src/reducer/transcript.test.ts`:
699
-
700
- ```ts
701
- describe('reduceTranscript / messages', () => {
702
- function startedTurn() {
703
- return reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 100 });
704
- }
705
-
706
- it('user_message appends a completed user item', () => {
707
- const m = reduceTranscript(startedTurn(), {
708
- type: 'user_message',
709
- turnId: 'tn-1',
710
- itemId: 'u1',
711
- text: 'hi',
712
- at: 110,
713
- });
714
- const item = m.turns[0]?.items[0];
715
- expect(item).toMatchObject({ kind: 'user_message', id: 'u1', text: 'hi', status: 'completed' });
716
- });
717
-
718
- it('agent_message partial creates a running assistant_text item', () => {
719
- const m = reduceTranscript(startedTurn(), {
720
- type: 'agent_message',
721
- turnId: 'tn-1',
722
- itemId: 'a1',
723
- text: 'hel',
724
- partial: true,
725
- at: 120,
726
- });
727
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'assistant_text', id: 'a1', text: 'hel', status: 'running' });
728
- });
729
-
730
- it('agent_message updates same itemId and flips to completed when partial=false', () => {
731
- let m = startedTurn();
732
- m = reduceTranscript(m, { type: 'agent_message', turnId: 'tn-1', itemId: 'a1', text: 'hel', partial: true, at: 120 });
733
- m = reduceTranscript(m, { type: 'agent_message', turnId: 'tn-1', itemId: 'a1', text: 'hello', partial: false, at: 130 });
734
- expect(m.turns[0]?.items).toHaveLength(1);
735
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'assistant_text', text: 'hello', status: 'completed' });
736
- });
737
-
738
- it('reasoning is independent from agent_message (not merged)', () => {
739
- let m = startedTurn();
740
- m = reduceTranscript(m, { type: 'reasoning', turnId: 'tn-1', itemId: 'r1', text: 'think', partial: false, at: 115 });
741
- m = reduceTranscript(m, { type: 'agent_message', turnId: 'tn-1', itemId: 'a1', text: 'answer', partial: false, at: 120 });
742
- expect(m.turns[0]?.items).toHaveLength(2);
743
- expect(m.turns[0]?.items[0]?.kind).toBe('reasoning');
744
- expect(m.turns[0]?.items[1]?.kind).toBe('assistant_text');
745
- });
746
- });
747
- ```
748
-
749
- - [ ] **Step 2: Run tests, expect new ones to fail**
750
-
751
- ```bash
752
- pnpm test -- src/reducer/transcript.test.ts
753
- ```
754
-
755
- Expected: 4 new tests FAIL.
756
-
757
- - [ ] **Step 3: Extend `transcript.ts`**
758
-
759
- Inside `reduceTranscript`, add helpers near top of file:
760
-
761
- ```ts
762
- function withinTurn(
763
- prev: TranscriptModel,
764
- turnId: string,
765
- at: number,
766
- mut: (turn: TurnView) => TurnView,
767
- ): TranscriptModel {
768
- const i = findTurnIndex(prev, turnId);
769
- if (i < 0) return { ...prev, lastEventAt: at };
770
- return replaceTurn(prev, i, mut(prev.turns[i]!), at);
771
- }
772
-
773
- function appendItem(turn: TurnView, item: ItemView): TurnView {
774
- return { ...turn, items: [...turn.items, item] };
775
- }
776
-
777
- function updateItem(turn: TurnView, id: string, mut: (item: ItemView) => ItemView): TurnView {
778
- let touched = false;
779
- const items = turn.items.map((it) => {
780
- if (it.id !== id) return it;
781
- touched = true;
782
- return mut(it);
783
- });
784
- return touched ? { ...turn, items } : turn;
785
- }
786
- ```
787
-
788
- Add cases inside the `switch`:
789
-
790
- ```ts
791
- case 'user_message':
792
- return withinTurn(prev, event.turnId, event.at, (t) =>
793
- appendItem(t, {
794
- id: event.itemId,
795
- kind: 'user_message',
796
- status: 'completed',
797
- startedAt: event.at,
798
- updatedAt: event.at,
799
- text: event.text,
800
- }),
801
- );
802
-
803
- case 'agent_message':
804
- case 'reasoning': {
805
- const kind = event.type === 'reasoning' ? 'reasoning' : 'assistant_text';
806
- return withinTurn(prev, event.turnId, event.at, (t) => {
807
- const exists = t.items.some((it) => it.id === event.itemId && it.kind === kind);
808
- if (exists) {
809
- return updateItem(t, event.itemId, (it) => {
810
- if (it.kind !== kind) return it;
811
- return { ...it, text: event.text, status: event.partial ? 'running' : 'completed', updatedAt: event.at };
812
- });
813
- }
814
- return appendItem(t, {
815
- id: event.itemId,
816
- kind,
817
- status: event.partial ? 'running' : 'completed',
818
- startedAt: event.at,
819
- updatedAt: event.at,
820
- text: event.text,
821
- } as ItemView);
822
- });
823
- }
824
- ```
825
-
826
- - [ ] **Step 4: Run tests**
827
-
828
- ```bash
829
- pnpm test -- src/reducer/transcript.test.ts
830
- ```
831
-
832
- Expected: ALL pass.
833
-
834
- - [ ] **Step 5: Commit**
835
-
836
- ```bash
837
- git add src/reducer/
838
- git commit -m "feat(reducer): user_message / agent_message / reasoning"
839
- ```
840
-
841
- ---
842
-
843
- ## Task 7: reducer + tool_call & exec pairing
844
-
845
- **Files:**
846
- - Modify: `src/reducer/transcript.ts`, `src/reducer/transcript.test.ts`
847
-
848
- - [ ] **Step 1: Append failing tests**
849
-
850
- ```ts
851
- describe('reduceTranscript / tool calls and exec', () => {
852
- const start = () => reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 100 });
853
-
854
- it('function_call creates pending tool_call', () => {
855
- const m = reduceTranscript(start(), {
856
- type: 'function_call',
857
- turnId: 'tn-1',
858
- callId: 'c1',
859
- name: 'getWeather',
860
- args: { city: 'NYC' },
861
- at: 110,
862
- });
863
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'tool_call', id: 'c1', name: 'getWeather', status: 'pending' });
864
- });
865
-
866
- it('function_call_output completes the matching tool_call', () => {
867
- let m = start();
868
- m = reduceTranscript(m, { type: 'function_call', turnId: 'tn-1', callId: 'c1', name: 'x', args: {}, at: 110 });
869
- m = reduceTranscript(m, { type: 'function_call_output', turnId: 'tn-1', callId: 'c1', output: { ok: true }, at: 120 });
870
- expect(m.turns[0]?.items[0]).toMatchObject({ status: 'completed', result: { ok: true } });
871
- });
872
-
873
- it('function_call_output with error marks failed', () => {
874
- let m = start();
875
- m = reduceTranscript(m, { type: 'function_call', turnId: 'tn-1', callId: 'c1', name: 'x', args: {}, at: 110 });
876
- m = reduceTranscript(m, { type: 'function_call_output', turnId: 'tn-1', callId: 'c1', error: 'boom', at: 120 });
877
- expect(m.turns[0]?.items[0]).toMatchObject({ status: 'failed', error: 'boom' });
878
- });
879
-
880
- it('exec_command_begin creates a running exec', () => {
881
- const m = reduceTranscript(start(), {
882
- type: 'exec_command_begin',
883
- turnId: 'tn-1',
884
- callId: 'e1',
885
- command: 'ls',
886
- at: 110,
887
- });
888
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'exec', id: 'e1', command: 'ls', status: 'running' });
889
- });
890
-
891
- it('exec_command_end with exit=0 completes; non-zero fails', () => {
892
- let m = reduceTranscript(start(), { type: 'exec_command_begin', turnId: 'tn-1', callId: 'e1', command: 'ls', at: 110 });
893
- m = reduceTranscript(m, { type: 'exec_command_end', turnId: 'tn-1', callId: 'e1', exit: 0, stdout: 'a\nb', stderr: '', durationMs: 5, at: 120 });
894
- expect(m.turns[0]?.items[0]).toMatchObject({ status: 'completed', exit: 0, stdout: 'a\nb', durationMs: 5 });
895
-
896
- let m2 = reduceTranscript(start(), { type: 'exec_command_begin', turnId: 'tn-1', callId: 'e2', command: 'false', at: 110 });
897
- m2 = reduceTranscript(m2, { type: 'exec_command_end', turnId: 'tn-1', callId: 'e2', exit: 1, stdout: '', stderr: 'no', durationMs: 1, at: 120 });
898
- expect(m2.turns[0]?.items[0]).toMatchObject({ status: 'failed', exit: 1, stderr: 'no' });
899
- });
900
-
901
- it('output without prior call is ignored gracefully (no throw, no item added)', () => {
902
- const m = reduceTranscript(start(), { type: 'function_call_output', turnId: 'tn-1', callId: 'missing', output: {}, at: 110 });
903
- expect(m.turns[0]?.items).toHaveLength(0);
904
- });
905
- });
906
- ```
907
-
908
- - [ ] **Step 2: Add cases to `transcript.ts`**
909
-
910
- ```ts
911
- case 'function_call':
912
- case 'mcp_tool_call':
913
- return withinTurn(prev, event.turnId, event.at, (t) =>
914
- appendItem(t, {
915
- id: event.callId,
916
- kind: 'tool_call',
917
- status: 'pending',
918
- startedAt: event.at,
919
- updatedAt: event.at,
920
- name: event.name,
921
- ...(event.type === 'mcp_tool_call' ? { server: event.server } : {}),
922
- args: event.args,
923
- } as ItemView),
924
- );
925
-
926
- case 'function_call_output':
927
- case 'mcp_tool_call_output':
928
- return withinTurn(prev, event.turnId, event.at, (t) =>
929
- updateItem(t, event.callId, (it) => {
930
- if (it.kind !== 'tool_call') return it;
931
- const failed = event.error !== undefined;
932
- return {
933
- ...it,
934
- status: failed ? 'failed' : 'completed',
935
- updatedAt: event.at,
936
- ...(failed ? { error: event.error } : { result: event.output }),
937
- };
938
- }),
939
- );
940
-
941
- case 'exec_command_begin':
942
- return withinTurn(prev, event.turnId, event.at, (t) =>
943
- appendItem(t, {
944
- id: event.callId,
945
- kind: 'exec',
946
- status: 'running',
947
- startedAt: event.at,
948
- updatedAt: event.at,
949
- command: event.command,
950
- }),
951
- );
952
-
953
- case 'exec_command_end':
954
- return withinTurn(prev, event.turnId, event.at, (t) =>
955
- updateItem(t, event.callId, (it) => {
956
- if (it.kind !== 'exec') return it;
957
- return {
958
- ...it,
959
- status: event.exit === 0 ? 'completed' : 'failed',
960
- updatedAt: event.at,
961
- exit: event.exit,
962
- stdout: event.stdout,
963
- stderr: event.stderr,
964
- durationMs: event.durationMs,
965
- };
966
- }),
967
- );
968
- ```
969
-
970
- - [ ] **Step 3: Run tests**
971
-
972
- ```bash
973
- pnpm test -- src/reducer/transcript.test.ts
974
- ```
975
-
976
- Expected: ALL pass.
977
-
978
- - [ ] **Step 4: Commit**
979
-
980
- ```bash
981
- git add src/reducer/
982
- git commit -m "feat(reducer): function_call / exec_command pairing"
983
- ```
984
-
985
- ---
986
-
987
- ## Task 8: reducer + web_search / patch / mcp + raw fallback
988
-
989
- **Files:**
990
- - Modify: `src/reducer/transcript.ts`, `src/reducer/transcript.test.ts`
991
-
992
- - [ ] **Step 1: Append failing tests**
993
-
994
- ```ts
995
- describe('reduceTranscript / search, patch, raw', () => {
996
- const start = () => reduceTranscript(EMPTY_MODEL, { type: 'turn_started', turnId: 'tn-1', at: 100 });
997
-
998
- it('web_search_call then web_search_end completes', () => {
999
- let m = reduceTranscript(start(), { type: 'web_search_call', turnId: 'tn-1', callId: 's1', query: 'ts', at: 110 });
1000
- m = reduceTranscript(m, { type: 'web_search_end', turnId: 'tn-1', callId: 's1', results: [{ title: 'T', url: 'https://x' }], at: 120 });
1001
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'search', status: 'completed', results: [{ title: 'T', url: 'https://x' }] });
1002
- });
1003
-
1004
- it('patch_apply_end ok=true => completed; ok=false => failed', () => {
1005
- const ok = reduceTranscript(start(), {
1006
- type: 'patch_apply_end', turnId: 'tn-1', callId: 'p1', files: [{ path: 'a.ts', status: 'modified' }], ok: true, at: 110,
1007
- });
1008
- expect(ok.turns[0]?.items[0]).toMatchObject({ kind: 'patch', status: 'completed', ok: true });
1009
-
1010
- const fail = reduceTranscript(start(), {
1011
- type: 'patch_apply_end', turnId: 'tn-1', callId: 'p2', files: [], ok: false, at: 110,
1012
- });
1013
- expect(fail.turns[0]?.items[0]).toMatchObject({ kind: 'patch', status: 'failed', ok: false });
1014
- });
1015
-
1016
- it('raw event is appended as kind=raw with payload preserved', () => {
1017
- const m = reduceTranscript(start(), { type: 'raw', turnId: 'tn-1', payload: { foo: 1 }, at: 110 });
1018
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'raw', payload: { foo: 1 }, status: 'completed' });
1019
- });
1020
-
1021
- it('raw event with no turnId is dropped silently (lastEventAt still updates)', () => {
1022
- const m = reduceTranscript(EMPTY_MODEL, { type: 'raw', payload: { lone: true }, at: 50 });
1023
- expect(m.turns).toEqual([]);
1024
- expect(m.lastEventAt).toBe(50);
1025
- });
1026
-
1027
- it('unknown event-shaped object is treated as raw via TS escape hatch', () => {
1028
- const unknownEvent = { type: 'foobar', turnId: 'tn-1', at: 110, payload: 1 } as unknown as Parameters<typeof reduceTranscript>[1];
1029
- const m = reduceTranscript(start(), unknownEvent);
1030
- expect(m.turns[0]?.items[0]).toMatchObject({ kind: 'raw' });
1031
- });
1032
- });
1033
- ```
1034
-
1035
- - [ ] **Step 2: Add cases to `transcript.ts`**
1036
-
1037
- ```ts
1038
- case 'web_search_call':
1039
- return withinTurn(prev, event.turnId, event.at, (t) =>
1040
- appendItem(t, {
1041
- id: event.callId,
1042
- kind: 'search',
1043
- status: 'pending',
1044
- startedAt: event.at,
1045
- updatedAt: event.at,
1046
- query: event.query,
1047
- }),
1048
- );
1049
-
1050
- case 'web_search_end':
1051
- return withinTurn(prev, event.turnId, event.at, (t) =>
1052
- updateItem(t, event.callId, (it) => {
1053
- if (it.kind !== 'search') return it;
1054
- return { ...it, status: 'completed', updatedAt: event.at, results: event.results };
1055
- }),
1056
- );
1057
-
1058
- case 'patch_apply_end':
1059
- return withinTurn(prev, event.turnId, event.at, (t) =>
1060
- appendItem(t, {
1061
- id: event.callId,
1062
- kind: 'patch',
1063
- status: event.ok ? 'completed' : 'failed',
1064
- startedAt: event.at,
1065
- updatedAt: event.at,
1066
- files: event.files,
1067
- ok: event.ok,
1068
- }),
1069
- );
1070
-
1071
- case 'raw': {
1072
- if (!event.turnId) return { ...prev, lastEventAt: event.at };
1073
- return withinTurn(prev, event.turnId, event.at, (t) =>
1074
- appendItem(t, {
1075
- id: event.itemId ?? `raw-${t.items.length}`,
1076
- kind: 'raw',
1077
- status: 'completed',
1078
- startedAt: event.at,
1079
- updatedAt: event.at,
1080
- payload: event.payload,
1081
- }),
1082
- );
1083
- }
1084
- ```
1085
-
1086
- Replace the `default:` case with raw fallback:
1087
-
1088
- ```ts
1089
- default: {
1090
- const e = event as { turnId?: string; at?: number; type?: string; [k: string]: unknown };
1091
- const at = typeof e.at === 'number' ? e.at : prev.lastEventAt;
1092
- if (!e.turnId) return { ...prev, lastEventAt: at };
1093
- return withinTurn(prev, e.turnId, at, (t) =>
1094
- appendItem(t, {
1095
- id: `raw-${t.items.length}`,
1096
- kind: 'raw',
1097
- status: 'completed',
1098
- startedAt: at,
1099
- updatedAt: at,
1100
- payload: e,
1101
- }),
1102
- );
1103
- }
1104
- ```
1105
-
1106
- - [ ] **Step 3: Run tests**
1107
-
1108
- ```bash
1109
- pnpm test -- src/reducer/transcript.test.ts
1110
- ```
1111
-
1112
- Expected: ALL pass.
1113
-
1114
- - [ ] **Step 4: Commit**
1115
-
1116
- ```bash
1117
- git add src/reducer/
1118
- git commit -m "feat(reducer): web_search, patch_apply, raw fallback"
1119
- ```
1120
-
1121
- ---
1122
-
1123
- ## Task 9: reducer property test (incremental == bulk)
1124
-
1125
- **Files:**
1126
- - Create: `src/reducer/property.test.ts`
1127
-
1128
- - [ ] **Step 1: Write the property test**
1129
-
1130
- ```ts
1131
- import { describe, expect, it } from 'vitest';
1132
- import type { ChatStreamEvent } from '../types/events.js';
1133
- import { EMPTY_MODEL } from '../types/model.js';
1134
- import { reduceTranscript } from './transcript.js';
1135
-
1136
- const SCENARIO: ChatStreamEvent[] = [
1137
- { type: 'thread_started', threadId: 'T', at: 1 },
1138
- { type: 'turn_started', turnId: 'A', at: 10 },
1139
- { type: 'user_message', turnId: 'A', itemId: 'u1', text: 'hi', at: 11 },
1140
- { type: 'reasoning', turnId: 'A', itemId: 'r1', text: 'thinking', partial: false, at: 12 },
1141
- { type: 'agent_message', turnId: 'A', itemId: 'a1', text: 'h', partial: true, at: 13 },
1142
- { type: 'agent_message', turnId: 'A', itemId: 'a1', text: 'hi', partial: false, at: 14 },
1143
- { type: 'function_call', turnId: 'A', callId: 'c1', name: 'echo', args: { x: 1 }, at: 15 },
1144
- { type: 'function_call_output', turnId: 'A', callId: 'c1', output: 'ok', at: 16 },
1145
- { type: 'exec_command_begin', turnId: 'A', callId: 'e1', command: 'ls', at: 17 },
1146
- { type: 'exec_command_end', turnId: 'A', callId: 'e1', exit: 0, stdout: 'a', stderr: '', durationMs: 5, at: 18 },
1147
- { type: 'web_search_call', turnId: 'A', callId: 's1', query: 'ts', at: 19 },
1148
- { type: 'web_search_end', turnId: 'A', callId: 's1', results: [{ title: 'T', url: 'https://x' }], at: 20 },
1149
- { type: 'patch_apply_end', turnId: 'A', callId: 'p1', files: [{ path: 'a.ts', status: 'modified' }], ok: true, at: 21 },
1150
- { type: 'turn_completed', turnId: 'A', at: 22, usage: { inputTokens: 1, outputTokens: 1 } },
1151
- { type: 'raw', turnId: 'A', payload: { weird: true }, at: 23 },
1152
- ];
1153
-
1154
- describe('reduceTranscript / property', () => {
1155
- it('incremental reduce equals bulk reduce', () => {
1156
- const bulk = SCENARIO.reduce(reduceTranscript, EMPTY_MODEL);
1157
-
1158
- let incremental = EMPTY_MODEL;
1159
- for (const e of SCENARIO) incremental = reduceTranscript(incremental, e);
1160
-
1161
- expect(incremental).toEqual(bulk);
1162
- });
1163
-
1164
- it('reducer never throws on a randomized order of valid events', () => {
1165
- const shuffled = [...SCENARIO].sort(() => Math.random() - 0.5);
1166
- expect(() => shuffled.reduce(reduceTranscript, EMPTY_MODEL)).not.toThrow();
1167
- });
1168
-
1169
- it('initial EMPTY_MODEL is not mutated by any number of reductions', () => {
1170
- const snap = JSON.stringify(EMPTY_MODEL);
1171
- SCENARIO.reduce(reduceTranscript, EMPTY_MODEL);
1172
- expect(JSON.stringify(EMPTY_MODEL)).toBe(snap);
1173
- });
1174
- });
1175
- ```
1176
-
1177
- - [ ] **Step 2: Run**
1178
-
1179
- ```bash
1180
- pnpm test -- src/reducer/property.test.ts
1181
- ```
1182
-
1183
- Expected: 3 PASS.
1184
-
1185
- - [ ] **Step 3: Commit**
1186
-
1187
- ```bash
1188
- git add src/reducer/property.test.ts
1189
- git commit -m "test(reducer): property tests for incremental==bulk and purity"
1190
- ```
1191
-
1192
- ---
1193
-
1194
- ## Task 10: inferStatus + status.ts (TDD)
1195
-
1196
- **Files:**
1197
- - Create: `src/reducer/status.ts`, `src/reducer/status.test.ts`
1198
-
1199
- - [ ] **Step 1: Write `src/reducer/status.test.ts`**
1200
-
1201
- ```ts
1202
- import { describe, expect, it } from 'vitest';
1203
- import type { TranscriptModel, TurnView } from '../types/model.js';
1204
- import { EMPTY_MODEL } from '../types/model.js';
1205
- import { inferStatus } from './status.js';
1206
-
1207
- function modelWithLastTurn(status: TurnView['status']): TranscriptModel {
1208
- return {
1209
- ...EMPTY_MODEL,
1210
- turns: [{ turnId: 'X', startedAt: 0, status, items: [] }],
1211
- lastEventAt: 1,
1212
- };
1213
- }
1214
-
1215
- describe('inferStatus', () => {
1216
- it('returns idle when no turns', () => {
1217
- expect(inferStatus(EMPTY_MODEL)).toBe('idle');
1218
- });
1219
- it('returns working when last turn running', () => {
1220
- expect(inferStatus(modelWithLastTurn('running'))).toBe('working');
1221
- });
1222
- it('returns failed when last turn failed', () => {
1223
- expect(inferStatus(modelWithLastTurn('failed'))).toBe('failed');
1224
- });
1225
- it('returns stopped when last turn aborted', () => {
1226
- expect(inferStatus(modelWithLastTurn('aborted'))).toBe('stopped');
1227
- });
1228
- it('returns completed when last turn completed', () => {
1229
- expect(inferStatus(modelWithLastTurn('completed'))).toBe('completed');
1230
- });
1231
- });
1232
- ```
1233
-
1234
- - [ ] **Step 2: Implement `src/reducer/status.ts`**
1235
-
1236
- ```ts
1237
- import type { TranscriptModel, TranscriptStatus } from '../types/model.js';
1238
-
1239
- export function inferStatus(model: TranscriptModel): TranscriptStatus {
1240
- const last = model.turns[model.turns.length - 1];
1241
- if (!last) return 'idle';
1242
- switch (last.status) {
1243
- case 'running': return 'working';
1244
- case 'failed': return 'failed';
1245
- case 'aborted': return 'stopped';
1246
- case 'completed': return 'completed';
1247
- }
1248
- }
1249
- ```
1250
-
1251
- - [ ] **Step 3: Run tests**
1252
-
1253
- ```bash
1254
- pnpm test -- src/reducer/status.test.ts
1255
- ```
1256
-
1257
- Expected: 5 PASS.
1258
-
1259
- - [ ] **Step 4: Commit**
1260
-
1261
- ```bash
1262
- git add src/reducer/status.ts src/reducer/status.test.ts
1263
- git commit -m "feat(reducer): inferStatus session-level state"
1264
- ```
1265
-
1266
- ---
1267
-
1268
- ## Task 11: useCodexTranscript hook (TDD, with incremental cache)
1269
-
1270
- **Files:**
1271
- - Create: `src/hooks/useCodexTranscript.ts`, `src/hooks/useCodexTranscript.test.ts`
1272
-
1273
- - [ ] **Step 1: Write the failing tests**
1274
-
1275
- ```ts
1276
- import { act, renderHook } from '@testing-library/react';
1277
- import { describe, expect, it, vi } from 'vitest';
1278
- import type { ChatStreamEvent } from '../types/events.js';
1279
- import { useCodexTranscript } from './useCodexTranscript.js';
1280
-
1281
- const baseEvents: ChatStreamEvent[] = [
1282
- { type: 'thread_started', threadId: 'T', at: 1 },
1283
- { type: 'turn_started', turnId: 'A', at: 2 },
1284
- { type: 'agent_message', turnId: 'A', itemId: 'a', text: 'hi', partial: false, at: 3 },
1285
- { type: 'turn_completed', turnId: 'A', at: 4 },
1286
- ];
1287
-
1288
- describe('useCodexTranscript', () => {
1289
- it('returns model and inferred status from events', () => {
1290
- const { result } = renderHook(() => useCodexTranscript(baseEvents));
1291
- expect(result.current.model.threadId).toBe('T');
1292
- expect(result.current.model.turns).toHaveLength(1);
1293
- expect(result.current.status).toBe('completed');
1294
- });
1295
-
1296
- it('explicit status overrides inference', () => {
1297
- const { result } = renderHook(() => useCodexTranscript(baseEvents, { status: 'stopped' }));
1298
- expect(result.current.status).toBe('stopped');
1299
- });
1300
-
1301
- it('append-only updates do incremental reduce (model identity changes only on change)', () => {
1302
- const { result, rerender } = renderHook(({ ev }: { ev: ChatStreamEvent[] }) => useCodexTranscript(ev), {
1303
- initialProps: { ev: baseEvents },
1304
- });
1305
- const before = result.current.model;
1306
- rerender({ ev: baseEvents }); // same array reference
1307
- expect(result.current.model).toBe(before);
1308
-
1309
- const more: ChatStreamEvent[] = [...baseEvents, { type: 'turn_started', turnId: 'B', at: 5 }];
1310
- rerender({ ev: more });
1311
- expect(result.current.model.turns).toHaveLength(2);
1312
- });
1313
-
1314
- it('non-prefix change triggers full re-reduce', () => {
1315
- const { result, rerender } = renderHook(({ ev }: { ev: ChatStreamEvent[] }) => useCodexTranscript(ev), {
1316
- initialProps: { ev: baseEvents },
1317
- });
1318
- const replaced: ChatStreamEvent[] = [{ type: 'thread_started', threadId: 'OTHER', at: 1 }];
1319
- rerender({ ev: replaced });
1320
- expect(result.current.model.threadId).toBe('OTHER');
1321
- expect(result.current.model.turns).toHaveLength(0);
1322
- });
1323
-
1324
- it('reducer error is captured by onInternalError and last good model is preserved', () => {
1325
- const onErr = vi.fn();
1326
- const ev: ChatStreamEvent[] = [
1327
- { type: 'turn_started', turnId: 'A', at: 1 },
1328
- { type: 'agent_message', turnId: 'A', itemId: 'm', text: 'ok', partial: false, at: 2 },
1329
- ];
1330
- const { result, rerender } = renderHook(({ events }: { events: ChatStreamEvent[] }) =>
1331
- useCodexTranscript(events, { onInternalError: onErr }),
1332
- { initialProps: { events: ev } });
1333
- expect(result.current.model.turns[0]?.items).toHaveLength(1);
1334
-
1335
- // Inject a bad event by mocking reduce internally is hard; emulate by passing a non-object-shaped raw.
1336
- const bad = [...ev, null as unknown as ChatStreamEvent];
1337
- act(() => rerender({ events: bad }));
1338
- expect(onErr).toHaveBeenCalled();
1339
- // Model should remain valid (one turn one item from the good prefix).
1340
- expect(result.current.model.turns[0]?.items).toHaveLength(1);
1341
- });
1342
- });
1343
- ```
1344
-
1345
- - [ ] **Step 2: Implement `src/hooks/useCodexTranscript.ts`**
1346
-
1347
- ```ts
1348
- import { useMemo, useRef } from 'react';
1349
- import type { ChatStreamEvent } from '../types/events.js';
1350
- import type { TranscriptModel, TranscriptStatus } from '../types/model.js';
1351
- import { EMPTY_MODEL } from '../types/model.js';
1352
- import { reduceTranscript } from '../reducer/transcript.js';
1353
- import { inferStatus } from '../reducer/status.js';
1354
-
1355
- export interface UseCodexTranscriptOptions {
1356
- status?: TranscriptStatus;
1357
- onInternalError?: (err: unknown, event?: ChatStreamEvent) => void;
1358
- }
1359
-
1360
- interface CacheEntry {
1361
- events: ChatStreamEvent[];
1362
- model: TranscriptModel;
1363
- }
1364
-
1365
- function safeReduce(
1366
- prev: TranscriptModel,
1367
- event: ChatStreamEvent,
1368
- onError?: (err: unknown, event?: ChatStreamEvent) => void,
1369
- ): TranscriptModel {
1370
- try {
1371
- return reduceTranscript(prev, event);
1372
- } catch (err) {
1373
- onError?.(err, event);
1374
- return prev;
1375
- }
1376
- }
1377
-
1378
- export function useCodexTranscript(
1379
- events: ChatStreamEvent[],
1380
- options: UseCodexTranscriptOptions = {},
1381
- ): { model: TranscriptModel; status: TranscriptStatus } {
1382
- const cacheRef = useRef<CacheEntry>({ events: [], model: EMPTY_MODEL });
1383
-
1384
- const model = useMemo(() => {
1385
- const cache = cacheRef.current;
1386
- const isPrefix =
1387
- events.length >= cache.events.length &&
1388
- cache.events.every((e, i) => e === events[i]);
1389
-
1390
- let next: TranscriptModel;
1391
- let startIdx: number;
1392
-
1393
- if (isPrefix) {
1394
- next = cache.model;
1395
- startIdx = cache.events.length;
1396
- } else {
1397
- next = EMPTY_MODEL;
1398
- startIdx = 0;
1399
- }
1400
-
1401
- for (let i = startIdx; i < events.length; i += 1) {
1402
- const ev = events[i];
1403
- if (ev == null || typeof ev !== 'object') {
1404
- options.onInternalError?.(new TypeError('non-object event'), ev as ChatStreamEvent);
1405
- continue;
1406
- }
1407
- next = safeReduce(next, ev, options.onInternalError);
1408
- }
1409
-
1410
- cacheRef.current = { events, model: next };
1411
- return next;
1412
- // eslint-disable-next-line react-hooks/exhaustive-deps
1413
- }, [events]);
1414
-
1415
- const status = options.status ?? inferStatus(model);
1416
- return { model, status };
1417
- }
1418
- ```
1419
-
1420
- - [ ] **Step 3: Run tests**
1421
-
1422
- ```bash
1423
- pnpm test -- src/hooks/useCodexTranscript.test.ts
1424
- ```
1425
-
1426
- Expected: 5 PASS.
1427
-
1428
- - [ ] **Step 4: Commit**
1429
-
1430
- ```bash
1431
- git add src/hooks/
1432
- git commit -m "feat(hook): useCodexTranscript with incremental reduce + error capture"
1433
- ```
1434
-
1435
- ---
1436
-
1437
- ## Task 12: useSmoothStream hook (TDD with fake timers)
1438
-
1439
- **Files:**
1440
- - Create: `src/hooks/useSmoothStream.ts`, `src/hooks/useSmoothStream.test.ts`
1441
-
1442
- - [ ] **Step 1: Write tests**
1443
-
1444
- ```ts
1445
- import { act, renderHook } from '@testing-library/react';
1446
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1447
- import { useSmoothStream } from './useSmoothStream.js';
1448
-
1449
- beforeEach(() => {
1450
- vi.useFakeTimers();
1451
- let raf = 0;
1452
- vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
1453
- raf += 1;
1454
- setTimeout(() => cb(performance.now()), 16);
1455
- return raf;
1456
- });
1457
- vi.stubGlobal('cancelAnimationFrame', () => {});
1458
- });
1459
-
1460
- afterEach(() => {
1461
- vi.useRealTimers();
1462
- vi.unstubAllGlobals();
1463
- });
1464
-
1465
- describe('useSmoothStream', () => {
1466
- it('returns full text immediately when disabled', () => {
1467
- const { result } = renderHook(() => useSmoothStream('hello', { enabled: false }));
1468
- expect(result.current).toBe('hello');
1469
- });
1470
-
1471
- it('progressively reveals characters when enabled', async () => {
1472
- const { result } = renderHook(() => useSmoothStream('hello'));
1473
- expect(result.current.length).toBeLessThan(5);
1474
- await act(async () => { await vi.advanceTimersByTimeAsync(500); });
1475
- expect(result.current).toBe('hello');
1476
- });
1477
-
1478
- it('truncates when input shrinks (reset)', async () => {
1479
- const { result, rerender } = renderHook(({ s }: { s: string }) => useSmoothStream(s), {
1480
- initialProps: { s: 'hello world' },
1481
- });
1482
- await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
1483
- expect(result.current).toBe('hello world');
1484
- rerender({ s: 'hi' });
1485
- await act(async () => { await vi.advanceTimersByTimeAsync(200); });
1486
- expect(result.current).toBe('hi');
1487
- });
1488
- });
1489
- ```
1490
-
1491
- - [ ] **Step 2: Implement `src/hooks/useSmoothStream.ts`**
1492
-
1493
- ```ts
1494
- import { useEffect, useRef, useState } from 'react';
1495
-
1496
- export interface UseSmoothStreamOptions {
1497
- enabled?: boolean;
1498
- /** When > 0, override automatic chars-per-frame calculation. */
1499
- charsPerFrame?: number;
1500
- minDelayMs?: number;
1501
- }
1502
-
1503
- const HAS_SEGMENTER = typeof Intl !== 'undefined' && typeof (Intl as unknown as { Segmenter?: unknown }).Segmenter === 'function';
1504
-
1505
- function segments(text: string): string[] {
1506
- if (!HAS_SEGMENTER) return Array.from(text);
1507
- const seg = new (Intl as unknown as { Segmenter: new (locale?: string, opts?: { granularity: 'grapheme' }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter(undefined, { granularity: 'grapheme' });
1508
- const out: string[] = [];
1509
- for (const part of seg.segment(text)) out.push(part.segment);
1510
- return out;
1511
- }
1512
-
1513
- function reducedMotion(): boolean {
1514
- return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1515
- }
1516
-
1517
- export function useSmoothStream(fullText: string, options: UseSmoothStreamOptions = {}): string {
1518
- const { enabled = true, charsPerFrame, minDelayMs = 16 } = options;
1519
- const [shown, setShown] = useState<string>(() => (!enabled || reducedMotion() ? fullText : ''));
1520
- const targetRef = useRef<string[]>(segments(fullText));
1521
- const idxRef = useRef<number>((!enabled || reducedMotion()) ? targetRef.current.length : 0);
1522
- const lastTickRef = useRef<number>(0);
1523
- const rafRef = useRef<number | null>(null);
1524
-
1525
- useEffect(() => {
1526
- if (!enabled || reducedMotion()) {
1527
- setShown(fullText);
1528
- targetRef.current = segments(fullText);
1529
- idxRef.current = targetRef.current.length;
1530
- return;
1531
- }
1532
- const newSegs = segments(fullText);
1533
- const oldSegs = targetRef.current;
1534
- targetRef.current = newSegs;
1535
- const isPrefix = newSegs.length >= oldSegs.length && oldSegs.every((s, i) => s === newSegs[i]);
1536
- if (!isPrefix) {
1537
- // text shrunk or replaced — reset
1538
- idxRef.current = 0;
1539
- setShown('');
1540
- } else if (idxRef.current > newSegs.length) {
1541
- idxRef.current = newSegs.length;
1542
- }
1543
-
1544
- const tick = (t: number) => {
1545
- const target = targetRef.current;
1546
- const remaining = target.length - idxRef.current;
1547
- if (remaining <= 0) { rafRef.current = null; return; }
1548
- const elapsed = t - lastTickRef.current;
1549
- if (elapsed < minDelayMs) {
1550
- rafRef.current = requestAnimationFrame(tick);
1551
- return;
1552
- }
1553
- lastTickRef.current = t;
1554
- const step = Math.max(1, charsPerFrame ?? Math.ceil(remaining / 8));
1555
- idxRef.current = Math.min(target.length, idxRef.current + step);
1556
- setShown(target.slice(0, idxRef.current).join(''));
1557
- rafRef.current = requestAnimationFrame(tick);
1558
- };
1559
- rafRef.current = requestAnimationFrame(tick);
1560
- return () => {
1561
- if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
1562
- rafRef.current = null;
1563
- };
1564
- }, [fullText, enabled, charsPerFrame, minDelayMs]);
1565
-
1566
- return shown;
1567
- }
1568
- ```
1569
-
1570
- - [ ] **Step 3: Run tests**
1571
-
1572
- ```bash
1573
- pnpm test -- src/hooks/useSmoothStream.test.ts
1574
- ```
1575
-
1576
- Expected: 3 PASS.
1577
-
1578
- - [ ] **Step 4: Commit**
1579
-
1580
- ```bash
1581
- git add src/hooks/useSmoothStream.ts src/hooks/useSmoothStream.test.ts
1582
- git commit -m "feat(hook): useSmoothStream typewriter effect"
1583
- ```
1584
-
1585
- ---
1586
-
1587
- ## Task 13: Style basics — tokens.css + reset + icons
1588
-
1589
- **Files:**
1590
- - Create: `src/styles/tokens.css`, `src/styles/reset.module.css`, `src/components/icons.ts`
1591
-
1592
- - [ ] **Step 1: Write `src/styles/tokens.css`**
1593
-
1594
- ```css
1595
- :root,
1596
- .codexview-root {
1597
- --cv-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
1598
- --cv-font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
1599
- --cv-font-size: 14px;
1600
- --cv-line-height: 1.55;
1601
- --cv-radius: 12px;
1602
- --cv-radius-sm: 8px;
1603
- --cv-spacing-xs: 4px;
1604
- --cv-spacing-sm: 8px;
1605
- --cv-spacing-md: 12px;
1606
- --cv-spacing-lg: 16px;
1607
-
1608
- --cv-text: #1f2328;
1609
- --cv-text-muted: #6e7781;
1610
- --cv-text-inverse: #ffffff;
1611
- --cv-bg: #ffffff;
1612
- --cv-bg-raised: #f6f8fa;
1613
- --cv-bg-user-bubble: #2f6feb;
1614
- --cv-bg-assistant-bubble: #f6f8fa;
1615
- --cv-bg-code: #0d1117;
1616
- --cv-fg-code: #e6edf3;
1617
- --cv-border: #d0d7de;
1618
- --cv-axis-color: #d0d7de;
1619
- --cv-shimmer-color: rgba(31, 35, 40, 0.08);
1620
-
1621
- --cv-status-pending: #6e7781;
1622
- --cv-status-running: #2f6feb;
1623
- --cv-status-completed: #1a7f37;
1624
- --cv-status-failed: #cf222e;
1625
- --cv-status-stopped: #6e7781;
1626
-
1627
- --cv-diff-add-bg: #ddf4e4;
1628
- --cv-diff-del-bg: #ffebe9;
1629
- }
1630
- ```
1631
-
1632
- - [ ] **Step 2: Write `src/styles/reset.module.css`**
1633
-
1634
- ```css
1635
- .root {
1636
- all: initial;
1637
- display: block;
1638
- font-family: var(--cv-font-family);
1639
- font-size: var(--cv-font-size);
1640
- line-height: var(--cv-line-height);
1641
- color: var(--cv-text);
1642
- background: var(--cv-bg);
1643
- box-sizing: border-box;
1644
- }
1645
- .root *,
1646
- .root *::before,
1647
- .root *::after {
1648
- box-sizing: inherit;
1649
- }
1650
- ```
1651
-
1652
- - [ ] **Step 3: Write `src/components/icons.ts`**
1653
-
1654
- ```ts
1655
- import {
1656
- AlertCircle,
1657
- ChevronDown,
1658
- ChevronRight,
1659
- CircleCheck,
1660
- CircleSlash,
1661
- CircleX,
1662
- FileEdit,
1663
- Globe,
1664
- MessageSquare,
1665
- Search,
1666
- Sparkles,
1667
- Terminal,
1668
- Wrench,
1669
- } from 'lucide-react';
1670
-
1671
- export const ICONS = {
1672
- tool: Wrench,
1673
- exec: Terminal,
1674
- patch: FileEdit,
1675
- search: Search,
1676
- web: Globe,
1677
- message: MessageSquare,
1678
- reasoning: Sparkles,
1679
- ok: CircleCheck,
1680
- fail: CircleX,
1681
- stop: CircleSlash,
1682
- warn: AlertCircle,
1683
- expand: ChevronDown,
1684
- collapse: ChevronRight,
1685
- } as const;
1686
-
1687
- export type IconKey = keyof typeof ICONS;
1688
- ```
1689
-
1690
- - [ ] **Step 4: Verify typecheck**
1691
-
1692
- ```bash
1693
- pnpm typecheck
1694
- ```
1695
-
1696
- Expected: exit 0.
1697
-
1698
- - [ ] **Step 5: Commit**
1699
-
1700
- ```bash
1701
- git add src/styles/ src/components/icons.ts
1702
- git commit -m "feat(styles): tokens, reset, lucide icon map"
1703
- ```
1704
-
1705
- ---
1706
-
1707
- ## Task 14: ItemErrorBoundary + StatusBar
1708
-
1709
- **Files:**
1710
- - Create: `src/components/ItemErrorBoundary.tsx`
1711
- - Create: `src/components/StatusBar.tsx`, `src/components/StatusBar.module.css`, `src/components/StatusBar.test.tsx`
1712
-
1713
- - [ ] **Step 1: Write `ItemErrorBoundary.tsx`**
1714
-
1715
- ```tsx
1716
- import { Component, type ErrorInfo, type ReactNode } from 'react';
1717
-
1718
- export interface ItemErrorBoundaryProps {
1719
- fallback?: ReactNode;
1720
- onError?: (err: unknown, info: ErrorInfo) => void;
1721
- children: ReactNode;
1722
- }
1723
-
1724
- interface State { hasError: boolean }
1725
-
1726
- export class ItemErrorBoundary extends Component<ItemErrorBoundaryProps, State> {
1727
- override state: State = { hasError: false };
1728
-
1729
- static getDerivedStateFromError(): State { return { hasError: true }; }
1730
-
1731
- override componentDidCatch(err: unknown, info: ErrorInfo): void {
1732
- this.props.onError?.(err, info);
1733
- }
1734
-
1735
- override render(): ReactNode {
1736
- if (this.state.hasError) return this.props.fallback ?? null;
1737
- return this.props.children;
1738
- }
1739
- }
1740
- ```
1741
-
1742
- - [ ] **Step 2: Write `StatusBar.tsx`**
1743
-
1744
- ```tsx
1745
- import type { TranscriptStatus } from '../types/model.js';
1746
- import styles from './StatusBar.module.css';
1747
-
1748
- export interface StatusBarProps {
1749
- status: TranscriptStatus;
1750
- label?: string;
1751
- error?: { message: string; details?: string };
1752
- }
1753
-
1754
- const LABELS: Record<TranscriptStatus, string> = {
1755
- idle: '',
1756
- working: '正在工作',
1757
- completed: '已完成',
1758
- stopped: '已停止',
1759
- failed: '出错',
1760
- };
1761
-
1762
- export function StatusBar({ status, label, error }: StatusBarProps): JSX.Element | null {
1763
- if (status === 'idle') return null;
1764
- const text = label ?? LABELS[status];
1765
- return (
1766
- <div className={styles.bar} data-status={status} role="status" aria-live="polite">
1767
- {status === 'working' && <span aria-hidden className={styles.pulse} />}
1768
- <span className={styles.label}>{text}</span>
1769
- {error && (
1770
- <details className={styles.errorDetails}>
1771
- <summary>{error.message}</summary>
1772
- {error.details && <pre>{error.details}</pre>}
1773
- </details>
1774
- )}
1775
- </div>
1776
- );
1777
- }
1778
- ```
1779
-
1780
- - [ ] **Step 3: Write `StatusBar.module.css`**
1781
-
1782
- ```css
1783
- .bar {
1784
- display: flex;
1785
- align-items: center;
1786
- gap: var(--cv-spacing-sm);
1787
- padding: var(--cv-spacing-sm) var(--cv-spacing-md);
1788
- border-bottom: 1px solid var(--cv-border);
1789
- background: var(--cv-bg-raised);
1790
- font-size: 0.875rem;
1791
- }
1792
- .bar[data-status='working'] { color: var(--cv-status-running); }
1793
- .bar[data-status='completed'] { color: var(--cv-status-completed); }
1794
- .bar[data-status='failed'] { color: var(--cv-status-failed); }
1795
- .bar[data-status='stopped'] { color: var(--cv-status-stopped); }
1796
-
1797
- .pulse {
1798
- width: 8px;
1799
- height: 8px;
1800
- border-radius: 50%;
1801
- background: currentColor;
1802
- animation: cv-pulse 1.4s ease-in-out infinite;
1803
- }
1804
- @keyframes cv-pulse {
1805
- 0%, 100% { opacity: 1; transform: scale(1); }
1806
- 50% { opacity: 0.4; transform: scale(0.8); }
1807
- }
1808
- @media (prefers-reduced-motion: reduce) {
1809
- .pulse { animation: none; opacity: 1; }
1810
- }
1811
- .label { font-weight: 500; }
1812
- .errorDetails { margin-left: auto; max-width: 50%; }
1813
- .errorDetails summary { cursor: pointer; }
1814
- .errorDetails pre { font-size: 0.75rem; overflow-x: auto; }
1815
- ```
1816
-
1817
- - [ ] **Step 4: Write `StatusBar.test.tsx`**
1818
-
1819
- ```tsx
1820
- import { render, screen } from '@testing-library/react';
1821
- import { describe, expect, it } from 'vitest';
1822
- import { StatusBar } from './StatusBar.js';
1823
-
1824
- describe('StatusBar', () => {
1825
- it('renders nothing when status=idle', () => {
1826
- const { container } = render(<StatusBar status="idle" />);
1827
- expect(container.firstChild).toBeNull();
1828
- });
1829
-
1830
- it('renders working with default label and pulse', () => {
1831
- render(<StatusBar status="working" />);
1832
- expect(screen.getByRole('status')).toHaveTextContent('正在工作');
1833
- });
1834
-
1835
- it('uses custom label when provided', () => {
1836
- render(<StatusBar status="working" label="思考中" />);
1837
- expect(screen.getByRole('status')).toHaveTextContent('思考中');
1838
- });
1839
-
1840
- it('renders error details when status=failed', () => {
1841
- render(<StatusBar status="failed" error={{ message: 'oops', details: 'stack' }} />);
1842
- expect(screen.getByRole('status')).toHaveTextContent('oops');
1843
- });
1844
- });
1845
- ```
1846
-
1847
- - [ ] **Step 5: Run tests**
1848
-
1849
- ```bash
1850
- pnpm test -- src/components/StatusBar.test.tsx
1851
- ```
1852
-
1853
- Expected: 4 PASS.
1854
-
1855
- - [ ] **Step 6: Commit**
1856
-
1857
- ```bash
1858
- git add src/components/ItemErrorBoundary.tsx src/components/StatusBar.tsx src/components/StatusBar.module.css src/components/StatusBar.test.tsx
1859
- git commit -m "feat(ui): ItemErrorBoundary + StatusBar"
1860
- ```
1861
-
1862
- ---
1863
-
1864
- ## Task 15: TurnContainer + MessageBubble
1865
-
1866
- **Files:**
1867
- - Create: `src/components/TurnContainer.tsx`, `src/components/TurnContainer.module.css`
1868
- - Create: `src/components/MessageBubble.tsx`, `src/components/MessageBubble.module.css`, `src/components/MessageBubble.test.tsx`
1869
-
1870
- - [ ] **Step 1: Write `TurnContainer.tsx`**
1871
-
1872
- ```tsx
1873
- import type { ReactNode } from 'react';
1874
- import type { TurnView } from '../types/model.js';
1875
- import styles from './TurnContainer.module.css';
1876
-
1877
- export interface TurnContainerProps {
1878
- turn: TurnView;
1879
- children: ReactNode;
1880
- }
1881
-
1882
- export function TurnContainer({ turn, children }: TurnContainerProps): JSX.Element {
1883
- return (
1884
- <section className={styles.turn} data-turn-id={turn.turnId} data-turn-status={turn.status}>
1885
- <div className={styles.axis}>{children}</div>
1886
- </section>
1887
- );
1888
- }
1889
- ```
1890
-
1891
- - [ ] **Step 2: Write `TurnContainer.module.css`**
1892
-
1893
- ```css
1894
- .turn {
1895
- padding: var(--cv-spacing-md) var(--cv-spacing-lg);
1896
- display: flex;
1897
- flex-direction: column;
1898
- gap: var(--cv-spacing-sm);
1899
- }
1900
- .axis {
1901
- position: relative;
1902
- padding-left: var(--cv-spacing-lg);
1903
- border-left: 2px solid var(--cv-axis-color);
1904
- display: flex;
1905
- flex-direction: column;
1906
- gap: var(--cv-spacing-sm);
1907
- }
1908
- .turn[data-turn-status='running'] .axis { border-left-color: var(--cv-status-running); }
1909
- .turn[data-turn-status='failed'] .axis { border-left-color: var(--cv-status-failed); }
1910
- .turn[data-turn-status='aborted'] .axis { border-left-color: var(--cv-status-stopped); }
1911
- ```
1912
-
1913
- - [ ] **Step 3: Write `MessageBubble.tsx`**
1914
-
1915
- ```tsx
1916
- import type { ItemView } from '../types/model.js';
1917
- import { useSmoothStream } from '../hooks/useSmoothStream.js';
1918
- import styles from './MessageBubble.module.css';
1919
-
1920
- export interface MessageBubbleProps {
1921
- item: Extract<ItemView, { kind: 'user_message' | 'assistant_text' }>;
1922
- smoothStream?: boolean;
1923
- }
1924
-
1925
- export function MessageBubble({ item, smoothStream = true }: MessageBubbleProps): JSX.Element {
1926
- const isUser = item.kind === 'user_message';
1927
- const enabled = !isUser && smoothStream && item.status === 'running';
1928
- const text = useSmoothStream(item.text, { enabled });
1929
- return (
1930
- <div
1931
- className={styles.bubble}
1932
- data-role={isUser ? 'user' : 'assistant'}
1933
- data-status={item.status}
1934
- >
1935
- <span className={styles.text}>{text}</span>
1936
- {item.status === 'running' && <span aria-hidden className={styles.caret}>▋</span>}
1937
- </div>
1938
- );
1939
- }
1940
- ```
1941
-
1942
- - [ ] **Step 4: Write `MessageBubble.module.css`**
1943
-
1944
- ```css
1945
- .bubble {
1946
- max-width: 80%;
1947
- padding: var(--cv-spacing-sm) var(--cv-spacing-md);
1948
- border-radius: var(--cv-radius);
1949
- white-space: pre-wrap;
1950
- word-break: break-word;
1951
- }
1952
- .bubble[data-role='user'] {
1953
- align-self: flex-end;
1954
- background: var(--cv-bg-user-bubble);
1955
- color: var(--cv-text-inverse);
1956
- }
1957
- .bubble[data-role='assistant'] {
1958
- background: var(--cv-bg-assistant-bubble);
1959
- color: var(--cv-text);
1960
- }
1961
- .caret {
1962
- display: inline-block;
1963
- margin-left: 2px;
1964
- animation: cv-blink 1s steps(2) infinite;
1965
- }
1966
- @keyframes cv-blink { 50% { opacity: 0; } }
1967
- @media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
1968
- ```
1969
-
1970
- - [ ] **Step 5: Write `MessageBubble.test.tsx`**
1971
-
1972
- ```tsx
1973
- import { render, screen } from '@testing-library/react';
1974
- import { describe, expect, it } from 'vitest';
1975
- import { MessageBubble } from './MessageBubble.js';
1976
-
1977
- describe('MessageBubble', () => {
1978
- it('renders user message with role=user', () => {
1979
- render(<MessageBubble item={{ id: 'u', kind: 'user_message', status: 'completed', startedAt: 0, updatedAt: 0, text: 'hi' }} />);
1980
- const el = screen.getByText('hi').closest('div')!;
1981
- expect(el.dataset.role).toBe('user');
1982
- });
1983
-
1984
- it('renders running assistant with caret', () => {
1985
- render(
1986
- <MessageBubble
1987
- smoothStream={false}
1988
- item={{ id: 'a', kind: 'assistant_text', status: 'running', startedAt: 0, updatedAt: 0, text: 'partial' }}
1989
- />,
1990
- );
1991
- expect(screen.getByText('partial')).toBeInTheDocument();
1992
- expect(screen.getByText('▋')).toBeInTheDocument();
1993
- });
1994
-
1995
- it('completed assistant has no caret', () => {
1996
- render(
1997
- <MessageBubble
1998
- smoothStream={false}
1999
- item={{ id: 'a', kind: 'assistant_text', status: 'completed', startedAt: 0, updatedAt: 0, text: 'done' }}
2000
- />,
2001
- );
2002
- expect(screen.queryByText('▋')).toBeNull();
2003
- });
2004
- });
2005
- ```
2006
-
2007
- - [ ] **Step 6: Run tests**
2008
-
2009
- ```bash
2010
- pnpm test -- src/components/MessageBubble.test.tsx
2011
- ```
2012
-
2013
- Expected: 3 PASS.
2014
-
2015
- - [ ] **Step 7: Commit**
2016
-
2017
- ```bash
2018
- git add src/components/TurnContainer.tsx src/components/TurnContainer.module.css src/components/MessageBubble.tsx src/components/MessageBubble.module.css src/components/MessageBubble.test.tsx
2019
- git commit -m "feat(ui): TurnContainer + MessageBubble"
2020
- ```
2021
-
2022
- ---
2023
-
2024
- ## Task 16: ReasoningBlock
2025
-
2026
- **Files:**
2027
- - Create: `src/components/ReasoningBlock.tsx`, `src/components/ReasoningBlock.module.css`, `src/components/ReasoningBlock.test.tsx`
2028
-
2029
- - [ ] **Step 1: Write `ReasoningBlock.tsx`**
2030
-
2031
- ```tsx
2032
- import type { ItemView } from '../types/model.js';
2033
- import { useSmoothStream } from '../hooks/useSmoothStream.js';
2034
- import { ICONS } from './icons.js';
2035
- import styles from './ReasoningBlock.module.css';
2036
-
2037
- export interface ReasoningBlockProps {
2038
- item: Extract<ItemView, { kind: 'reasoning' }>;
2039
- defaultOpen?: boolean;
2040
- smoothStream?: boolean;
2041
- }
2042
-
2043
- function durationLabel(ms: number): string {
2044
- if (ms < 1000) return `${ms}ms`;
2045
- return `${(ms / 1000).toFixed(1)}s`;
2046
- }
2047
-
2048
- export function ReasoningBlock({ item, defaultOpen = false, smoothStream = true }: ReasoningBlockProps): JSX.Element {
2049
- const Icon = ICONS.reasoning;
2050
- const live = item.status === 'running';
2051
- const text = useSmoothStream(item.text, { enabled: smoothStream && live });
2052
- const elapsed = item.updatedAt - item.startedAt;
2053
- return (
2054
- <details className={styles.block} open={defaultOpen || live}>
2055
- <summary className={styles.summary}>
2056
- <Icon size={14} aria-hidden />
2057
- <span>{live ? '思考中…' : `思考 (${durationLabel(elapsed)})`}</span>
2058
- </summary>
2059
- <div className={styles.body}>{text}</div>
2060
- </details>
2061
- );
2062
- }
2063
- ```
2064
-
2065
- - [ ] **Step 2: Write `ReasoningBlock.module.css`**
2066
-
2067
- ```css
2068
- .block {
2069
- border-left: 2px solid var(--cv-border);
2070
- padding-left: var(--cv-spacing-sm);
2071
- }
2072
- .summary {
2073
- display: inline-flex;
2074
- align-items: center;
2075
- gap: var(--cv-spacing-xs);
2076
- cursor: pointer;
2077
- color: var(--cv-text-muted);
2078
- font-style: italic;
2079
- list-style: none;
2080
- }
2081
- .summary::-webkit-details-marker { display: none; }
2082
- .body {
2083
- margin-top: var(--cv-spacing-xs);
2084
- color: var(--cv-text-muted);
2085
- font-style: italic;
2086
- white-space: pre-wrap;
2087
- }
2088
- ```
2089
-
2090
- - [ ] **Step 3: Write `ReasoningBlock.test.tsx`**
2091
-
2092
- ```tsx
2093
- import { render, screen } from '@testing-library/react';
2094
- import { describe, expect, it } from 'vitest';
2095
- import { ReasoningBlock } from './ReasoningBlock.js';
2096
-
2097
- describe('ReasoningBlock', () => {
2098
- it('shows thinking-in-progress label and is open while running', () => {
2099
- render(
2100
- <ReasoningBlock smoothStream={false} item={{ id: 'r', kind: 'reasoning', status: 'running', startedAt: 0, updatedAt: 100, text: 'hmm' }} />,
2101
- );
2102
- expect(screen.getByText(/思考中/)).toBeInTheDocument();
2103
- expect(screen.getByText('hmm')).toBeInTheDocument();
2104
- });
2105
-
2106
- it('shows duration when completed and is collapsed by default', () => {
2107
- render(
2108
- <ReasoningBlock smoothStream={false} item={{ id: 'r', kind: 'reasoning', status: 'completed', startedAt: 0, updatedAt: 1500, text: 'done' }} />,
2109
- );
2110
- expect(screen.getByText(/思考 \(1\.5s\)/)).toBeInTheDocument();
2111
- const details = screen.getByText(/思考/).closest('details')!;
2112
- expect(details.open).toBe(false);
2113
- });
2114
- });
2115
- ```
2116
-
2117
- - [ ] **Step 4: Run tests**
2118
-
2119
- ```bash
2120
- pnpm test -- src/components/ReasoningBlock.test.tsx
2121
- ```
2122
-
2123
- Expected: 2 PASS.
2124
-
2125
- - [ ] **Step 5: Commit**
2126
-
2127
- ```bash
2128
- git add src/components/ReasoningBlock.*
2129
- git commit -m "feat(ui): ReasoningBlock collapsed by default with duration"
2130
- ```
2131
-
2132
- ---
2133
-
2134
- ## Task 17: ToolCallBlock + ExecBlock
2135
-
2136
- **Files:**
2137
- - Create: `src/components/ToolCallBlock.tsx`, `src/components/ToolCallBlock.module.css`, `src/components/ToolCallBlock.test.tsx`
2138
- - Create: `src/components/ExecBlock.tsx`, `src/components/ExecBlock.module.css`, `src/components/ExecBlock.test.tsx`
2139
- - Create: `src/components/_shared.ts` (collapse helpers shared by tool/exec/search)
2140
-
2141
- - [ ] **Step 1: Write `src/components/_shared.ts`**
2142
-
2143
- ```ts
2144
- const MAX_INLINE_CHARS = 500;
2145
- const MAX_INLINE_LINES = 4;
2146
- const MAX_DEPTH = 3;
2147
-
2148
- function depth(value: unknown, current = 0): number {
2149
- if (value == null || typeof value !== 'object' || current > MAX_DEPTH) return current;
2150
- let max = current;
2151
- for (const v of Object.values(value as Record<string, unknown>)) {
2152
- max = Math.max(max, depth(v, current + 1));
2153
- }
2154
- return max;
2155
- }
2156
-
2157
- export function shouldCollapseValue(value: unknown): boolean {
2158
- if (typeof value === 'string') {
2159
- if (value.length > MAX_INLINE_CHARS) return true;
2160
- if (value.split('\n').length > MAX_INLINE_LINES) return true;
2161
- return false;
2162
- }
2163
- try {
2164
- const json = JSON.stringify(value, null, 2);
2165
- if (json == null) return false;
2166
- if (json.length > MAX_INLINE_CHARS) return true;
2167
- if (json.split('\n').length > MAX_INLINE_LINES) return true;
2168
- if (depth(value) > MAX_DEPTH) return true;
2169
- return false;
2170
- } catch {
2171
- return true;
2172
- }
2173
- }
2174
-
2175
- export function safeStringify(value: unknown): string {
2176
- try { return JSON.stringify(value, null, 2) ?? String(value); }
2177
- catch { return '[unserializable]'; }
2178
- }
2179
- ```
2180
-
2181
- - [ ] **Step 2: Write `ToolCallBlock.tsx`**
2182
-
2183
- ```tsx
2184
- import type { ItemView } from '../types/model.js';
2185
- import { ICONS } from './icons.js';
2186
- import { safeStringify, shouldCollapseValue } from './_shared.js';
2187
- import styles from './ToolCallBlock.module.css';
2188
-
2189
- export interface ToolCallBlockProps {
2190
- item: Extract<ItemView, { kind: 'tool_call' }>;
2191
- }
2192
-
2193
- function phrase(name: string, server?: string): string {
2194
- return server ? `${server}.${name}` : name;
2195
- }
2196
-
2197
- export function ToolCallBlock({ item }: ToolCallBlockProps): JSX.Element {
2198
- const Icon = ICONS.tool;
2199
- return (
2200
- <div className={styles.block} data-status={item.status}>
2201
- <header className={styles.header}>
2202
- <Icon size={14} aria-hidden />
2203
- <span className={styles.title}>{phrase(item.name, item.server)}</span>
2204
- <span className={styles.statusChip}>{item.status}</span>
2205
- </header>
2206
- <div className={styles.args}>
2207
- <div className={styles.label}>args</div>
2208
- <pre className={styles.code}>{safeStringify(item.args)}</pre>
2209
- </div>
2210
- {item.error !== undefined && (
2211
- <div className={styles.error}>{item.error}</div>
2212
- )}
2213
- {item.result !== undefined && (
2214
- shouldCollapseValue(item.result) ? (
2215
- <details className={styles.result}>
2216
- <summary>result</summary>
2217
- <pre className={styles.code}>{safeStringify(item.result)}</pre>
2218
- </details>
2219
- ) : (
2220
- <div className={styles.result}>
2221
- <div className={styles.label}>result</div>
2222
- <pre className={styles.code}>{safeStringify(item.result)}</pre>
2223
- </div>
2224
- )
2225
- )}
2226
- </div>
2227
- );
2228
- }
2229
- ```
2230
-
2231
- - [ ] **Step 3: Write `ToolCallBlock.module.css`**
2232
-
2233
- ```css
2234
- .block {
2235
- border: 1px solid var(--cv-border);
2236
- border-radius: var(--cv-radius-sm);
2237
- padding: var(--cv-spacing-sm);
2238
- background: var(--cv-bg-raised);
2239
- display: flex;
2240
- flex-direction: column;
2241
- gap: var(--cv-spacing-xs);
2242
- }
2243
- .block[data-status='pending'] { border-left: 3px solid var(--cv-status-pending); }
2244
- .block[data-status='running'] { border-left: 3px solid var(--cv-status-running); }
2245
- .block[data-status='completed'] { border-left: 3px solid var(--cv-status-completed); }
2246
- .block[data-status='failed'] { border-left: 3px solid var(--cv-status-failed); }
2247
- .block[data-status='stopped'] { border-left: 3px solid var(--cv-status-stopped); }
2248
-
2249
- .header { display: flex; align-items: center; gap: var(--cv-spacing-xs); }
2250
- .title { font-weight: 600; }
2251
- .statusChip { margin-left: auto; font-size: 0.75rem; color: var(--cv-text-muted); }
2252
- .label { font-size: 0.75rem; color: var(--cv-text-muted); }
2253
- .code {
2254
- background: var(--cv-bg-code); color: var(--cv-fg-code);
2255
- padding: var(--cv-spacing-sm); border-radius: var(--cv-radius-sm);
2256
- font-family: var(--cv-font-mono); font-size: 0.8rem; overflow-x: auto;
2257
- white-space: pre-wrap;
2258
- }
2259
- .error { color: var(--cv-status-failed); font-family: var(--cv-font-mono); font-size: 0.8rem; }
2260
- .result summary { cursor: pointer; font-size: 0.75rem; color: var(--cv-text-muted); }
2261
- ```
2262
-
2263
- - [ ] **Step 4: Write `ToolCallBlock.test.tsx`**
2264
-
2265
- ```tsx
2266
- import { render, screen } from '@testing-library/react';
2267
- import { describe, expect, it } from 'vitest';
2268
- import { ToolCallBlock } from './ToolCallBlock.js';
2269
-
2270
- describe('ToolCallBlock', () => {
2271
- it('renders pending tool call without result', () => {
2272
- render(<ToolCallBlock item={{ id: 'c', kind: 'tool_call', status: 'pending', startedAt: 0, updatedAt: 0, name: 'getWeather', args: { city: 'NYC' } }} />);
2273
- expect(screen.getByText('getWeather')).toBeInTheDocument();
2274
- expect(screen.queryByText('result')).toBeNull();
2275
- });
2276
-
2277
- it('renders inline result when small', () => {
2278
- render(<ToolCallBlock item={{ id: 'c', kind: 'tool_call', status: 'completed', startedAt: 0, updatedAt: 0, name: 'x', args: {}, result: { ok: true } }} />);
2279
- expect(screen.getByText('result')).toBeInTheDocument();
2280
- });
2281
-
2282
- it('renders collapsed result when large', () => {
2283
- const big = 'x'.repeat(600);
2284
- render(<ToolCallBlock item={{ id: 'c', kind: 'tool_call', status: 'completed', startedAt: 0, updatedAt: 0, name: 'x', args: {}, result: big }} />);
2285
- const details = screen.getByText('result').closest('details');
2286
- expect(details).not.toBeNull();
2287
- expect(details!.open).toBe(false);
2288
- });
2289
-
2290
- it('renders error in failed state', () => {
2291
- render(<ToolCallBlock item={{ id: 'c', kind: 'tool_call', status: 'failed', startedAt: 0, updatedAt: 0, name: 'x', args: {}, error: 'boom' }} />);
2292
- expect(screen.getByText('boom')).toBeInTheDocument();
2293
- });
2294
-
2295
- it('prefixes name with server for MCP tools', () => {
2296
- render(<ToolCallBlock item={{ id: 'c', kind: 'tool_call', status: 'pending', startedAt: 0, updatedAt: 0, name: 'list', server: 'fs', args: {} }} />);
2297
- expect(screen.getByText('fs.list')).toBeInTheDocument();
2298
- });
2299
- });
2300
- ```
2301
-
2302
- - [ ] **Step 5: Write `ExecBlock.tsx`**
2303
-
2304
- ```tsx
2305
- import type { ItemView } from '../types/model.js';
2306
- import { ICONS } from './icons.js';
2307
- import { shouldCollapseValue } from './_shared.js';
2308
- import styles from './ExecBlock.module.css';
2309
-
2310
- export interface ExecBlockProps {
2311
- item: Extract<ItemView, { kind: 'exec' }>;
2312
- }
2313
-
2314
- export function ExecBlock({ item }: ExecBlockProps): JSX.Element {
2315
- const Icon = ICONS.exec;
2316
- const running = item.status === 'running';
2317
- const exitText = item.exit != null ? `(exit ${item.exit}, ${item.durationMs ?? '?'}ms)` : '';
2318
- const stdoutCollapse = shouldCollapseValue(item.stdout ?? '');
2319
- const stderrCollapse = shouldCollapseValue(item.stderr ?? '');
2320
- return (
2321
- <div className={styles.block} data-status={item.status}>
2322
- <header className={styles.header}>
2323
- <Icon size={14} aria-hidden />
2324
- <code className={styles.cmd}>$ {item.command}</code>
2325
- <span className={styles.exit}>{exitText}</span>
2326
- </header>
2327
- {running && <div className={styles.shimmer} aria-hidden />}
2328
- {item.stdout && (
2329
- stdoutCollapse ? (
2330
- <details><summary>stdout</summary><pre className={styles.stdout}>{item.stdout}</pre></details>
2331
- ) : <pre className={styles.stdout}>{item.stdout}</pre>
2332
- )}
2333
- {item.stderr && (
2334
- stderrCollapse ? (
2335
- <details><summary>stderr</summary><pre className={styles.stderr}>{item.stderr}</pre></details>
2336
- ) : <pre className={styles.stderr}>{item.stderr}</pre>
2337
- )}
2338
- </div>
2339
- );
2340
- }
2341
- ```
2342
-
2343
- - [ ] **Step 6: Write `ExecBlock.module.css`**
2344
-
2345
- ```css
2346
- .block {
2347
- background: var(--cv-bg-code);
2348
- color: var(--cv-fg-code);
2349
- border-radius: var(--cv-radius-sm);
2350
- padding: var(--cv-spacing-sm);
2351
- display: flex; flex-direction: column; gap: var(--cv-spacing-xs);
2352
- font-family: var(--cv-font-mono); font-size: 0.85rem;
2353
- overflow: hidden;
2354
- }
2355
- .header { display: flex; align-items: center; gap: var(--cv-spacing-xs); }
2356
- .cmd { white-space: pre-wrap; word-break: break-word; flex: 1; }
2357
- .exit { font-size: 0.75rem; opacity: 0.7; }
2358
- .stdout { white-space: pre-wrap; word-break: break-word; }
2359
- .stderr { white-space: pre-wrap; word-break: break-word; color: var(--cv-status-failed); }
2360
- .shimmer {
2361
- height: 4px; border-radius: 2px;
2362
- background: linear-gradient(90deg, transparent, var(--cv-shimmer-color), transparent);
2363
- background-size: 200% 100%;
2364
- animation: cv-shimmer 1.4s linear infinite;
2365
- }
2366
- @keyframes cv-shimmer {
2367
- from { background-position: -100% 0; }
2368
- to { background-position: 100% 0; }
2369
- }
2370
- @media (prefers-reduced-motion: reduce) { .shimmer { animation: none; } }
2371
- ```
2372
-
2373
- - [ ] **Step 7: Write `ExecBlock.test.tsx`**
2374
-
2375
- ```tsx
2376
- import { render, screen } from '@testing-library/react';
2377
- import { describe, expect, it } from 'vitest';
2378
- import { ExecBlock } from './ExecBlock.js';
2379
-
2380
- describe('ExecBlock', () => {
2381
- it('renders command and exit status', () => {
2382
- render(<ExecBlock item={{ id: 'e', kind: 'exec', status: 'completed', startedAt: 0, updatedAt: 0, command: 'ls', exit: 0, stdout: 'a', stderr: '', durationMs: 5 }} />);
2383
- expect(screen.getByText('$ ls')).toBeInTheDocument();
2384
- expect(screen.getByText(/exit 0/)).toBeInTheDocument();
2385
- });
2386
-
2387
- it('shows stderr in red when present', () => {
2388
- render(<ExecBlock item={{ id: 'e', kind: 'exec', status: 'failed', startedAt: 0, updatedAt: 0, command: 'x', exit: 1, stdout: '', stderr: 'no', durationMs: 1 }} />);
2389
- expect(screen.getByText('no')).toBeInTheDocument();
2390
- });
2391
- });
2392
- ```
2393
-
2394
- - [ ] **Step 8: Run tests**
2395
-
2396
- ```bash
2397
- pnpm test -- src/components/ToolCallBlock.test.tsx src/components/ExecBlock.test.tsx
2398
- ```
2399
-
2400
- Expected: 7 PASS total.
2401
-
2402
- - [ ] **Step 9: Commit**
2403
-
2404
- ```bash
2405
- git add src/components/_shared.ts src/components/ToolCallBlock.* src/components/ExecBlock.*
2406
- git commit -m "feat(ui): ToolCallBlock + ExecBlock with collapse heuristics"
2407
- ```
2408
-
2409
- ---
2410
-
2411
- ## Task 18: SearchBlock + PatchBlock + RawEventBlock
2412
-
2413
- **Files:**
2414
- - Create: `src/components/SearchBlock.tsx`, `.module.css`, `.test.tsx`
2415
- - Create: `src/components/PatchBlock.tsx`, `.module.css`, `.test.tsx`
2416
- - Create: `src/components/RawEventBlock.tsx`, `.module.css`, `.test.tsx`
2417
-
2418
- - [ ] **Step 1: Write `SearchBlock.tsx`**
2419
-
2420
- ```tsx
2421
- import { useState } from 'react';
2422
- import type { ItemView } from '../types/model.js';
2423
- import { ICONS } from './icons.js';
2424
- import styles from './SearchBlock.module.css';
2425
-
2426
- export interface SearchBlockProps {
2427
- item: Extract<ItemView, { kind: 'search' }>;
2428
- initialVisible?: number;
2429
- }
2430
-
2431
- export function SearchBlock({ item, initialVisible = 3 }: SearchBlockProps): JSX.Element {
2432
- const [showAll, setShowAll] = useState(false);
2433
- const Icon = ICONS.search;
2434
- const results = item.results ?? [];
2435
- const visible = showAll ? results : results.slice(0, initialVisible);
2436
- const remaining = results.length - visible.length;
2437
- return (
2438
- <div className={styles.block} data-status={item.status}>
2439
- <header className={styles.header}>
2440
- <Icon size={14} aria-hidden />
2441
- <span className={styles.query}>{item.query}</span>
2442
- </header>
2443
- {results.length > 0 && (
2444
- <ol className={styles.results}>
2445
- {visible.map((r) => (
2446
- <li key={r.url}>
2447
- <a href={r.url} target="_blank" rel="noreferrer">{r.title}</a>
2448
- {r.snippet && <p className={styles.snippet}>{r.snippet}</p>}
2449
- </li>
2450
- ))}
2451
- </ol>
2452
- )}
2453
- {remaining > 0 && (
2454
- <button type="button" className={styles.more} onClick={() => setShowAll(true)}>
2455
- 展开剩余 {remaining} 条
2456
- </button>
2457
- )}
2458
- </div>
2459
- );
2460
- }
2461
- ```
2462
-
2463
- - [ ] **Step 2: Write `SearchBlock.module.css`**
2464
-
2465
- ```css
2466
- .block { display: flex; flex-direction: column; gap: var(--cv-spacing-xs); }
2467
- .header { display: flex; align-items: center; gap: var(--cv-spacing-xs); font-weight: 500; }
2468
- .query { color: var(--cv-text); }
2469
- .results { padding-left: var(--cv-spacing-md); margin: 0; }
2470
- .results li { margin-bottom: var(--cv-spacing-xs); }
2471
- .results a { color: var(--cv-status-running); text-decoration: underline; }
2472
- .snippet { margin: 2px 0 0; color: var(--cv-text-muted); font-size: 0.85rem; }
2473
- .more {
2474
- align-self: flex-start;
2475
- background: none; border: none; cursor: pointer;
2476
- color: var(--cv-status-running); padding: 0; font-size: 0.85rem;
2477
- }
2478
- ```
2479
-
2480
- - [ ] **Step 3: Write `SearchBlock.test.tsx`**
2481
-
2482
- ```tsx
2483
- import { render, screen, fireEvent } from '@testing-library/react';
2484
- import { describe, expect, it } from 'vitest';
2485
- import { SearchBlock } from './SearchBlock.js';
2486
-
2487
- describe('SearchBlock', () => {
2488
- it('shows query and limited results', () => {
2489
- render(<SearchBlock item={{ id: 's', kind: 'search', status: 'completed', startedAt: 0, updatedAt: 0, query: 'ts', results: [
2490
- { title: 'A', url: 'https://a' },
2491
- { title: 'B', url: 'https://b' },
2492
- { title: 'C', url: 'https://c' },
2493
- { title: 'D', url: 'https://d' },
2494
- ] }} />);
2495
- expect(screen.getByText('ts')).toBeInTheDocument();
2496
- expect(screen.getByText('A')).toBeInTheDocument();
2497
- expect(screen.queryByText('D')).toBeNull();
2498
- expect(screen.getByText(/展开剩余 1 条/)).toBeInTheDocument();
2499
- });
2500
-
2501
- it('expands all on click', () => {
2502
- render(<SearchBlock item={{ id: 's', kind: 'search', status: 'completed', startedAt: 0, updatedAt: 0, query: 'q', results: [
2503
- { title: 'A', url: 'https://a' }, { title: 'B', url: 'https://b' }, { title: 'C', url: 'https://c' }, { title: 'D', url: 'https://d' },
2504
- ] }} />);
2505
- fireEvent.click(screen.getByRole('button'));
2506
- expect(screen.getByText('D')).toBeInTheDocument();
2507
- });
2508
- });
2509
- ```
2510
-
2511
- - [ ] **Step 4: Write `PatchBlock.tsx`**
2512
-
2513
- ```tsx
2514
- import type { ItemView } from '../types/model.js';
2515
- import { ICONS } from './icons.js';
2516
- import styles from './PatchBlock.module.css';
2517
-
2518
- export interface PatchBlockProps {
2519
- item: Extract<ItemView, { kind: 'patch' }>;
2520
- }
2521
-
2522
- function colorLines(diff: string): JSX.Element[] {
2523
- return diff.split('\n').map((line, i) => {
2524
- let cls = '';
2525
- if (line.startsWith('+') && !line.startsWith('+++')) cls = styles.add!;
2526
- else if (line.startsWith('-') && !line.startsWith('---')) cls = styles.del!;
2527
- return <span key={i} className={cls}>{line}{'\n'}</span>;
2528
- });
2529
- }
2530
-
2531
- export function PatchBlock({ item }: PatchBlockProps): JSX.Element {
2532
- const Icon = ICONS.patch;
2533
- return (
2534
- <div className={styles.block} data-status={item.status}>
2535
- <header className={styles.header}>
2536
- <Icon size={14} aria-hidden />
2537
- <span>{item.files.length} 个文件 ({item.ok ? '成功' : '失败'})</span>
2538
- </header>
2539
- <ul className={styles.files}>
2540
- {item.files.map((f) => (
2541
- <li key={f.path}>
2542
- <details>
2543
- <summary>
2544
- <code>{f.path}</code>
2545
- <span className={styles.tag} data-kind={f.status}>{f.status}</span>
2546
- </summary>
2547
- {f.diff && <pre className={styles.diff}>{colorLines(f.diff)}</pre>}
2548
- </details>
2549
- </li>
2550
- ))}
2551
- </ul>
2552
- </div>
2553
- );
2554
- }
2555
- ```
2556
-
2557
- - [ ] **Step 5: Write `PatchBlock.module.css`**
2558
-
2559
- ```css
2560
- .block { display: flex; flex-direction: column; gap: var(--cv-spacing-xs); }
2561
- .header { display: flex; align-items: center; gap: var(--cv-spacing-xs); font-weight: 500; }
2562
- .files { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; }
2563
- .files summary { display: flex; align-items: center; gap: var(--cv-spacing-xs); cursor: pointer; padding: 2px 0; }
2564
- .tag { font-size: 0.7rem; padding: 1px 6px; border-radius: 4px; background: var(--cv-bg-raised); color: var(--cv-text-muted); }
2565
- .tag[data-kind='added'] { background: var(--cv-diff-add-bg); color: var(--cv-status-completed); }
2566
- .tag[data-kind='deleted'] { background: var(--cv-diff-del-bg); color: var(--cv-status-failed); }
2567
- .diff {
2568
- background: var(--cv-bg-code); color: var(--cv-fg-code);
2569
- padding: var(--cv-spacing-sm); border-radius: var(--cv-radius-sm);
2570
- font-family: var(--cv-font-mono); font-size: 0.8rem;
2571
- white-space: pre-wrap; overflow-x: auto;
2572
- }
2573
- .add { background: var(--cv-diff-add-bg); color: #032b14; }
2574
- .del { background: var(--cv-diff-del-bg); color: #67060c; }
2575
- ```
2576
-
2577
- - [ ] **Step 6: Write `PatchBlock.test.tsx`**
2578
-
2579
- ```tsx
2580
- import { render, screen } from '@testing-library/react';
2581
- import { describe, expect, it } from 'vitest';
2582
- import { PatchBlock } from './PatchBlock.js';
2583
-
2584
- describe('PatchBlock', () => {
2585
- it('lists files with status tags', () => {
2586
- render(<PatchBlock item={{ id: 'p', kind: 'patch', status: 'completed', startedAt: 0, updatedAt: 0, files: [
2587
- { path: 'a.ts', status: 'modified', diff: '+ new\n- old' },
2588
- ], ok: true }} />);
2589
- expect(screen.getByText('a.ts')).toBeInTheDocument();
2590
- expect(screen.getByText('modified')).toBeInTheDocument();
2591
- });
2592
- });
2593
- ```
2594
-
2595
- - [ ] **Step 7: Write `RawEventBlock.tsx`**
2596
-
2597
- ```tsx
2598
- import type { ItemView } from '../types/model.js';
2599
- import { ICONS } from './icons.js';
2600
- import { safeStringify } from './_shared.js';
2601
- import styles from './RawEventBlock.module.css';
2602
-
2603
- export interface RawEventBlockProps {
2604
- item: Extract<ItemView, { kind: 'raw' }>;
2605
- }
2606
-
2607
- export function RawEventBlock({ item }: RawEventBlockProps): JSX.Element {
2608
- const Icon = ICONS.warn;
2609
- const typeLabel = (() => {
2610
- const p = item.payload;
2611
- if (p && typeof p === 'object' && 'type' in p) return String((p as { type: unknown }).type);
2612
- return 'unknown';
2613
- })();
2614
- return (
2615
- <details className={styles.block}>
2616
- <summary className={styles.summary}>
2617
- <Icon size={14} aria-hidden />
2618
- <span>未知事件: {typeLabel}</span>
2619
- </summary>
2620
- <pre className={styles.code}>{safeStringify(item.payload)}</pre>
2621
- </details>
2622
- );
2623
- }
2624
- ```
2625
-
2626
- - [ ] **Step 8: Write `RawEventBlock.module.css`**
2627
-
2628
- ```css
2629
- .block { border: 1px dashed var(--cv-border); border-radius: var(--cv-radius-sm); padding: var(--cv-spacing-xs) var(--cv-spacing-sm); background: var(--cv-bg-raised); }
2630
- .summary { display: flex; align-items: center; gap: var(--cv-spacing-xs); cursor: pointer; color: var(--cv-text-muted); list-style: none; }
2631
- .summary::-webkit-details-marker { display: none; }
2632
- .code {
2633
- margin: var(--cv-spacing-xs) 0 0;
2634
- background: var(--cv-bg-code); color: var(--cv-fg-code);
2635
- padding: var(--cv-spacing-sm); border-radius: var(--cv-radius-sm);
2636
- font-family: var(--cv-font-mono); font-size: 0.8rem; white-space: pre-wrap;
2637
- }
2638
- ```
2639
-
2640
- - [ ] **Step 9: Write `RawEventBlock.test.tsx`**
2641
-
2642
- ```tsx
2643
- import { render, screen } from '@testing-library/react';
2644
- import { describe, expect, it } from 'vitest';
2645
- import { RawEventBlock } from './RawEventBlock.js';
2646
-
2647
- describe('RawEventBlock', () => {
2648
- it('shows unknown event with type label and payload preserved', () => {
2649
- render(<RawEventBlock item={{ id: 'r', kind: 'raw', status: 'completed', startedAt: 0, updatedAt: 0, payload: { type: 'foobar', x: 1 } }} />);
2650
- expect(screen.getByText(/未知事件: foobar/)).toBeInTheDocument();
2651
- expect(screen.getByText(/"x": 1/)).toBeInTheDocument();
2652
- });
2653
- });
2654
- ```
2655
-
2656
- - [ ] **Step 10: Run all new tests**
2657
-
2658
- ```bash
2659
- pnpm test -- src/components/SearchBlock.test.tsx src/components/PatchBlock.test.tsx src/components/RawEventBlock.test.tsx
2660
- ```
2661
-
2662
- Expected: 4 PASS.
2663
-
2664
- - [ ] **Step 11: Commit**
2665
-
2666
- ```bash
2667
- git add src/components/SearchBlock.* src/components/PatchBlock.* src/components/RawEventBlock.*
2668
- git commit -m "feat(ui): SearchBlock + PatchBlock + RawEventBlock"
2669
- ```
2670
-
2671
- ---
2672
-
2673
- ## Task 19: CodexTranscript main component + index.ts
2674
-
2675
- **Files:**
2676
- - Create: `src/components/CodexTranscript.tsx`, `src/components/CodexTranscript.module.css`, `src/components/CodexTranscript.test.tsx`
2677
- - Modify: `src/index.ts` (replace placeholder with full re-exports)
2678
-
2679
- - [ ] **Step 1: Write `CodexTranscript.tsx`**
2680
-
2681
- ```tsx
2682
- import { Fragment, useCallback, type ComponentType, type ReactNode } from 'react';
2683
- import type { ChatStreamEvent } from '../types/events.js';
2684
- import type { ItemView, TranscriptStatus, TurnView } from '../types/model.js';
2685
- import { useCodexTranscript } from '../hooks/useCodexTranscript.js';
2686
- import { ItemErrorBoundary } from './ItemErrorBoundary.js';
2687
- import { TurnContainer } from './TurnContainer.js';
2688
- import { MessageBubble, type MessageBubbleProps } from './MessageBubble.js';
2689
- import { ReasoningBlock, type ReasoningBlockProps } from './ReasoningBlock.js';
2690
- import { ToolCallBlock, type ToolCallBlockProps } from './ToolCallBlock.js';
2691
- import { ExecBlock, type ExecBlockProps } from './ExecBlock.js';
2692
- import { SearchBlock, type SearchBlockProps } from './SearchBlock.js';
2693
- import { PatchBlock, type PatchBlockProps } from './PatchBlock.js';
2694
- import { RawEventBlock, type RawEventBlockProps } from './RawEventBlock.js';
2695
- import { StatusBar, type StatusBarProps } from './StatusBar.js';
2696
- import resetStyles from '../styles/reset.module.css';
2697
- import styles from './CodexTranscript.module.css';
2698
-
2699
- export interface CodexTranscriptComponents {
2700
- StatusBar: ComponentType<StatusBarProps>;
2701
- MessageBubble: ComponentType<MessageBubbleProps>;
2702
- ReasoningBlock: ComponentType<ReasoningBlockProps>;
2703
- ToolCallBlock: ComponentType<ToolCallBlockProps>;
2704
- ExecBlock: ComponentType<ExecBlockProps>;
2705
- SearchBlock: ComponentType<SearchBlockProps>;
2706
- PatchBlock: ComponentType<PatchBlockProps>;
2707
- RawEventBlock: ComponentType<RawEventBlockProps>;
2708
- }
2709
-
2710
- export interface CodexTranscriptProps {
2711
- events: ChatStreamEvent[];
2712
- status?: TranscriptStatus;
2713
- error?: { message: string; details?: string };
2714
- className?: string;
2715
- maxItems?: number;
2716
- emptyState?: ReactNode;
2717
- onItemClick?: (itemId: string) => void;
2718
- components?: Partial<CodexTranscriptComponents>;
2719
- disableSmoothStream?: boolean;
2720
- onInternalError?: (err: unknown, event?: ChatStreamEvent) => void;
2721
- }
2722
-
2723
- const DEFAULTS: CodexTranscriptComponents = {
2724
- StatusBar,
2725
- MessageBubble,
2726
- ReasoningBlock,
2727
- ToolCallBlock,
2728
- ExecBlock,
2729
- SearchBlock,
2730
- PatchBlock,
2731
- RawEventBlock,
2732
- };
2733
-
2734
- function flatItems(turns: TurnView[]): { turn: TurnView; item: ItemView }[] {
2735
- const out: { turn: TurnView; item: ItemView }[] = [];
2736
- for (const t of turns) for (const i of t.items) out.push({ turn: t, item: i });
2737
- return out;
2738
- }
2739
-
2740
- export function CodexTranscript(props: CodexTranscriptProps): JSX.Element {
2741
- const opts: { status?: TranscriptStatus; onInternalError?: (err: unknown, event?: ChatStreamEvent) => void } = {};
2742
- if (props.status !== undefined) opts.status = props.status;
2743
- if (props.onInternalError) opts.onInternalError = props.onInternalError;
2744
- const { model, status } = useCodexTranscript(props.events, opts);
2745
- const components = { ...DEFAULTS, ...props.components };
2746
- const handleClick = useCallback(
2747
- (id: string) => () => props.onItemClick?.(id),
2748
- [props.onItemClick],
2749
- );
2750
-
2751
- const flat = flatItems(model.turns);
2752
- const truncated = props.maxItems != null && flat.length > props.maxItems
2753
- ? { kept: flat.slice(flat.length - props.maxItems), omitted: flat.length - props.maxItems }
2754
- : { kept: flat, omitted: 0 };
2755
-
2756
- if (model.turns.length === 0) {
2757
- return (
2758
- <div className={[resetStyles.root, styles.root, 'codexview-root', props.className].filter(Boolean).join(' ')}>
2759
- <components.StatusBar status={status} {...(props.error ? { error: props.error } : {})} />
2760
- <div className={styles.empty}>{props.emptyState ?? '暂无对话'}</div>
2761
- </div>
2762
- );
2763
- }
2764
-
2765
- // Group kept items by turn (for TurnContainer continuity).
2766
- const byTurn = new Map<string, ItemView[]>();
2767
- for (const { turn, item } of truncated.kept) {
2768
- const arr = byTurn.get(turn.turnId) ?? [];
2769
- arr.push(item);
2770
- byTurn.set(turn.turnId, arr);
2771
- }
2772
- const turnsToRender = model.turns.filter((t) => byTurn.has(t.turnId));
2773
-
2774
- return (
2775
- <div className={[resetStyles.root, styles.root, 'codexview-root', props.className].filter(Boolean).join(' ')}>
2776
- <components.StatusBar status={status} {...(props.error ? { error: props.error } : {})} />
2777
- {truncated.omitted > 0 && (
2778
- <div className={styles.omitted}>已省略最早的 {truncated.omitted} 条</div>
2779
- )}
2780
- <ol className={styles.list} role="log" aria-live="polite" aria-relevant="additions text">
2781
- {turnsToRender.map((turn) => (
2782
- <li key={turn.turnId}>
2783
- <TurnContainer turn={turn}>
2784
- {(byTurn.get(turn.turnId) ?? []).map((item) => (
2785
- <Fragment key={item.id}>
2786
- <ItemErrorBoundary fallback={<components.RawEventBlock item={{ id: item.id, kind: 'raw', status: item.status, startedAt: item.startedAt, updatedAt: item.updatedAt, payload: item }} />}>
2787
- <div onClick={handleClick(item.id)}>
2788
- {renderItem(item, components, props.disableSmoothStream)}
2789
- </div>
2790
- </ItemErrorBoundary>
2791
- </Fragment>
2792
- ))}
2793
- </TurnContainer>
2794
- </li>
2795
- ))}
2796
- </ol>
2797
- </div>
2798
- );
2799
- }
2800
-
2801
- function renderItem(item: ItemView, c: CodexTranscriptComponents, disableSmoothStream?: boolean): JSX.Element {
2802
- const smoothStream = !disableSmoothStream;
2803
- switch (item.kind) {
2804
- case 'user_message':
2805
- case 'assistant_text':
2806
- return <c.MessageBubble item={item} smoothStream={smoothStream} />;
2807
- case 'reasoning':
2808
- return <c.ReasoningBlock item={item} smoothStream={smoothStream} />;
2809
- case 'tool_call':
2810
- return <c.ToolCallBlock item={item} />;
2811
- case 'exec':
2812
- return <c.ExecBlock item={item} />;
2813
- case 'search':
2814
- return <c.SearchBlock item={item} />;
2815
- case 'patch':
2816
- return <c.PatchBlock item={item} />;
2817
- case 'raw':
2818
- return <c.RawEventBlock item={item} />;
2819
- }
2820
- }
2821
- ```
2822
-
2823
- - [ ] **Step 2: Write `CodexTranscript.module.css`**
2824
-
2825
- ```css
2826
- .root {
2827
- display: flex; flex-direction: column;
2828
- background: var(--cv-bg);
2829
- color: var(--cv-text);
2830
- min-height: 0;
2831
- }
2832
- .list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; }
2833
- .empty {
2834
- padding: var(--cv-spacing-lg);
2835
- color: var(--cv-text-muted);
2836
- text-align: center;
2837
- font-size: 0.9rem;
2838
- }
2839
- .omitted {
2840
- padding: var(--cv-spacing-xs) var(--cv-spacing-md);
2841
- font-size: 0.75rem;
2842
- color: var(--cv-text-muted);
2843
- background: var(--cv-bg-raised);
2844
- text-align: center;
2845
- }
2846
- ```
2847
-
2848
- - [ ] **Step 3: Write `CodexTranscript.test.tsx`**
2849
-
2850
- ```tsx
2851
- import { render, screen } from '@testing-library/react';
2852
- import { describe, expect, it } from 'vitest';
2853
- import type { ChatStreamEvent } from '../types/events.js';
2854
- import { CodexTranscript } from './CodexTranscript.js';
2855
-
2856
- describe('CodexTranscript', () => {
2857
- const ev: ChatStreamEvent[] = [
2858
- { type: 'thread_started', threadId: 'T', at: 1 },
2859
- { type: 'turn_started', turnId: 'A', at: 2 },
2860
- { type: 'user_message', turnId: 'A', itemId: 'u', text: 'hi', at: 3 },
2861
- { type: 'agent_message', turnId: 'A', itemId: 'a', text: 'hello', partial: false, at: 4 },
2862
- { type: 'turn_completed', turnId: 'A', at: 5 },
2863
- ];
2864
-
2865
- it('renders empty state when no events', () => {
2866
- render(<CodexTranscript events={[]} />);
2867
- expect(screen.getByText('暂无对话')).toBeInTheDocument();
2868
- });
2869
-
2870
- it('renders user + assistant messages', () => {
2871
- render(<CodexTranscript events={ev} disableSmoothStream />);
2872
- expect(screen.getByText('hi')).toBeInTheDocument();
2873
- expect(screen.getByText('hello')).toBeInTheDocument();
2874
- expect(screen.getByRole('log')).toBeInTheDocument();
2875
- expect(screen.getByRole('status')).toHaveTextContent('已完成');
2876
- });
2877
-
2878
- it('respects maxItems by trimming oldest', () => {
2879
- render(<CodexTranscript events={ev} maxItems={1} disableSmoothStream />);
2880
- expect(screen.getByText(/已省略最早的 1 条/)).toBeInTheDocument();
2881
- expect(screen.queryByText('hi')).toBeNull();
2882
- expect(screen.getByText('hello')).toBeInTheDocument();
2883
- });
2884
-
2885
- it('explicit status prop overrides inference', () => {
2886
- render(<CodexTranscript events={ev} status="stopped" disableSmoothStream />);
2887
- expect(screen.getByRole('status')).toHaveTextContent('已停止');
2888
- });
2889
- });
2890
- ```
2891
-
2892
- - [ ] **Step 4: Replace `src/index.ts`**
2893
-
2894
- ```ts
2895
- export type { ChatStreamEvent, ChatStreamEventType, TokenUsage, SearchResult, PatchFile } from './types/events.js';
2896
- export type { TranscriptModel, TurnView, ItemView, ItemKind, ItemStatus, TranscriptStatus } from './types/model.js';
2897
- export { EMPTY_MODEL } from './types/model.js';
2898
-
2899
- export { reduceTranscript } from './reducer/transcript.js';
2900
- export { inferStatus } from './reducer/status.js';
2901
- export { useCodexTranscript } from './hooks/useCodexTranscript.js';
2902
- export type { UseCodexTranscriptOptions } from './hooks/useCodexTranscript.js';
2903
- export { useSmoothStream } from './hooks/useSmoothStream.js';
2904
- export type { UseSmoothStreamOptions } from './hooks/useSmoothStream.js';
2905
-
2906
- export { CodexTranscript } from './components/CodexTranscript.js';
2907
- export type { CodexTranscriptProps, CodexTranscriptComponents } from './components/CodexTranscript.js';
2908
- export { StatusBar } from './components/StatusBar.js';
2909
- export type { StatusBarProps } from './components/StatusBar.js';
2910
- export { TurnContainer } from './components/TurnContainer.js';
2911
- export type { TurnContainerProps } from './components/TurnContainer.js';
2912
- export { MessageBubble } from './components/MessageBubble.js';
2913
- export type { MessageBubbleProps } from './components/MessageBubble.js';
2914
- export { ReasoningBlock } from './components/ReasoningBlock.js';
2915
- export type { ReasoningBlockProps } from './components/ReasoningBlock.js';
2916
- export { ToolCallBlock } from './components/ToolCallBlock.js';
2917
- export type { ToolCallBlockProps } from './components/ToolCallBlock.js';
2918
- export { ExecBlock } from './components/ExecBlock.js';
2919
- export type { ExecBlockProps } from './components/ExecBlock.js';
2920
- export { SearchBlock } from './components/SearchBlock.js';
2921
- export type { SearchBlockProps } from './components/SearchBlock.js';
2922
- export { PatchBlock } from './components/PatchBlock.js';
2923
- export type { PatchBlockProps } from './components/PatchBlock.js';
2924
- export { RawEventBlock } from './components/RawEventBlock.js';
2925
- export type { RawEventBlockProps } from './components/RawEventBlock.js';
2926
- export { ItemErrorBoundary } from './components/ItemErrorBoundary.js';
2927
- export type { ItemErrorBoundaryProps } from './components/ItemErrorBoundary.js';
2928
-
2929
- export const VERSION = '0.1.0';
2930
- ```
2931
-
2932
- - [ ] **Step 5: Run full test suite**
2933
-
2934
- ```bash
2935
- pnpm test
2936
- ```
2937
-
2938
- Expected: ALL pass.
2939
-
2940
- - [ ] **Step 6: Build and verify dist contents**
2941
-
2942
- ```bash
2943
- pnpm build && ls dist
2944
- ```
2945
-
2946
- Expected: `dist/index.js`, `dist/index.d.ts`, plus copied CSS assets (one or more `.css` files; tsup with `loader: { '.css': 'copy' }` will emit them).
2947
-
2948
- - [ ] **Step 7: Commit**
2949
-
2950
- ```bash
2951
- git add src/components/CodexTranscript.* src/index.ts
2952
- git commit -m "feat(ui): CodexTranscript main component + public exports"
2953
- ```
2954
-
2955
- ---
2956
-
2957
- ## Task 20: Fixtures + replay integration test
2958
-
2959
- **Files:**
2960
- - Create: `fixtures/README.md`, `fixtures/short-chat.jsonl`, `fixtures/tool-heavy.jsonl`, `fixtures/mcp-flow.jsonl`, `fixtures/failed-turn.jsonl`, `fixtures/aborted-turn.jsonl`, `fixtures/unknown-types.jsonl`
2961
- - Create: `src/integration/loadFixture.ts`, `src/integration/replay.test.tsx`
2962
-
2963
- - [ ] **Step 1: Write `fixtures/README.md`**
2964
-
2965
- ```markdown
2966
- # CodexView fixtures
2967
-
2968
- JSONL files representing `ChatStreamEvent` sequences for tests and the dev SPA.
2969
-
2970
- Each line is one event. To produce a new fixture from a real Codex session:
2971
-
2972
- 1. Find a rollout file in `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
2973
- 2. Run a normalization script that maps native Codex events to `ChatStreamEvent`
2974
- (the same mapping agentweb's `eventMap.ts` performs).
2975
- 3. Manually scrub: replace `cwd`, usernames, hostnames, API keys, and any
2976
- sensitive URL paths.
2977
- 4. Save to `fixtures/<name>.jsonl`.
2978
-
2979
- ## Inventory
2980
-
2981
- - `short-chat.jsonl` — single turn with one user + one assistant message
2982
- - `tool-heavy.jsonl` — multiple function_call + exec interleaved
2983
- - `mcp-flow.jsonl` — MCP tool call + web_search
2984
- - `failed-turn.jsonl` — turn ends in failure
2985
- - `aborted-turn.jsonl` — user aborts mid-turn
2986
- - `unknown-types.jsonl` — intentionally injects an unknown event type for the raw fallback
2987
- ```
2988
-
2989
- - [ ] **Step 2: Write `fixtures/short-chat.jsonl`**
2990
-
2991
- ```
2992
- {"type":"thread_started","threadId":"T-short","at":1000}
2993
- {"type":"turn_started","turnId":"A","at":1010}
2994
- {"type":"user_message","turnId":"A","itemId":"u1","text":"Hello, who are you?","at":1020}
2995
- {"type":"agent_message","turnId":"A","itemId":"a1","text":"I'm Codex, your coding assistant.","partial":false,"at":1030}
2996
- {"type":"turn_completed","turnId":"A","at":1040,"usage":{"inputTokens":12,"outputTokens":18}}
2997
- ```
2998
-
2999
- - [ ] **Step 3: Write `fixtures/tool-heavy.jsonl`**
3000
-
3001
- ```
3002
- {"type":"thread_started","threadId":"T-tool","at":2000}
3003
- {"type":"turn_started","turnId":"A","at":2010}
3004
- {"type":"user_message","turnId":"A","itemId":"u1","text":"List the files in src and run the tests.","at":2020}
3005
- {"type":"reasoning","turnId":"A","itemId":"r1","text":"I should look at the project layout first, then run the test command.","partial":false,"at":2030}
3006
- {"type":"function_call","turnId":"A","callId":"c1","name":"list_dir","args":{"path":"src"},"at":2040}
3007
- {"type":"function_call_output","turnId":"A","callId":"c1","output":["index.ts","reducer/","components/"],"at":2050}
3008
- {"type":"exec_command_begin","turnId":"A","callId":"e1","command":"pnpm test","at":2060}
3009
- {"type":"exec_command_end","turnId":"A","callId":"e1","exit":0,"stdout":"All tests passed","stderr":"","durationMs":1234,"at":2080}
3010
- {"type":"agent_message","turnId":"A","itemId":"a1","text":"Tests pass. The repo has index.ts, a reducer/ folder and a components/ folder.","partial":false,"at":2090}
3011
- {"type":"turn_completed","turnId":"A","at":2100,"usage":{"inputTokens":50,"outputTokens":80}}
3012
- ```
3013
-
3014
- - [ ] **Step 4: Write `fixtures/mcp-flow.jsonl`**
3015
-
3016
- ```
3017
- {"type":"thread_started","threadId":"T-mcp","at":3000}
3018
- {"type":"turn_started","turnId":"A","at":3010}
3019
- {"type":"user_message","turnId":"A","itemId":"u1","text":"Find recent React 19 release notes.","at":3020}
3020
- {"type":"web_search_call","turnId":"A","callId":"s1","query":"React 19 release notes","at":3030}
3021
- {"type":"web_search_end","turnId":"A","callId":"s1","results":[{"title":"React 19 is now stable","url":"https://example.com/react-19","snippet":"Server components, actions, ..."},{"title":"React 19 migration guide","url":"https://example.com/react-19-migrate"}],"at":3050}
3022
- {"type":"mcp_tool_call","turnId":"A","callId":"m1","server":"fs","name":"read_file","args":{"path":"NOTES.md"},"at":3060}
3023
- {"type":"mcp_tool_call_output","turnId":"A","callId":"m1","output":"Last updated 2026-05-01","at":3080}
3024
- {"type":"agent_message","turnId":"A","itemId":"a1","text":"React 19 is stable; key features are server components and actions.","partial":false,"at":3090}
3025
- {"type":"turn_completed","turnId":"A","at":3100}
3026
- ```
3027
-
3028
- - [ ] **Step 5: Write `fixtures/failed-turn.jsonl`**
3029
-
3030
- ```
3031
- {"type":"thread_started","threadId":"T-fail","at":4000}
3032
- {"type":"turn_started","turnId":"A","at":4010}
3033
- {"type":"user_message","turnId":"A","itemId":"u1","text":"Apply this patch.","at":4020}
3034
- {"type":"patch_apply_end","turnId":"A","callId":"p1","files":[{"path":"src/missing.ts","status":"modified"}],"ok":false,"at":4040}
3035
- {"type":"turn_failed","turnId":"A","at":4050,"error":{"message":"Patch did not apply cleanly","code":"PATCH_REJECTED"}}
3036
- ```
3037
-
3038
- - [ ] **Step 6: Write `fixtures/aborted-turn.jsonl`**
3039
-
3040
- ```
3041
- {"type":"thread_started","threadId":"T-abort","at":5000}
3042
- {"type":"turn_started","turnId":"A","at":5010}
3043
- {"type":"user_message","turnId":"A","itemId":"u1","text":"Run a long script.","at":5020}
3044
- {"type":"exec_command_begin","turnId":"A","callId":"e1","command":"sleep 60","at":5030}
3045
- {"type":"turn_aborted","turnId":"A","at":5050,"reason":"user_cancel"}
3046
- ```
3047
-
3048
- - [ ] **Step 7: Write `fixtures/unknown-types.jsonl`**
3049
-
3050
- ```
3051
- {"type":"thread_started","threadId":"T-raw","at":6000}
3052
- {"type":"turn_started","turnId":"A","at":6010}
3053
- {"type":"agent_message","turnId":"A","itemId":"a1","text":"Trying an unknown tool.","partial":false,"at":6020}
3054
- {"type":"some_future_event","turnId":"A","callId":"x1","extra":{"hello":"world"},"at":6030}
3055
- {"type":"raw","turnId":"A","payload":{"type":"explicit_raw","note":"this is intentional"},"at":6040}
3056
- {"type":"turn_completed","turnId":"A","at":6050}
3057
- ```
3058
-
3059
- - [ ] **Step 8: Write `src/integration/loadFixture.ts`**
3060
-
3061
- ```ts
3062
- import { readFileSync } from 'node:fs';
3063
- import { resolve } from 'node:path';
3064
- import { fileURLToPath } from 'node:url';
3065
- import type { ChatStreamEvent } from '../types/events.js';
3066
-
3067
- const HERE = fileURLToPath(new URL('.', import.meta.url));
3068
-
3069
- export function loadFixture(name: string): ChatStreamEvent[] {
3070
- const path = resolve(HERE, '..', '..', 'fixtures', `${name}.jsonl`);
3071
- const text = readFileSync(path, 'utf8');
3072
- return text
3073
- .split('\n')
3074
- .filter((line) => line.trim().length > 0)
3075
- .map((line) => JSON.parse(line) as ChatStreamEvent);
3076
- }
3077
- ```
3078
-
3079
- - [ ] **Step 9: Write `src/integration/replay.test.tsx`**
3080
-
3081
- ```tsx
3082
- import { render } from '@testing-library/react';
3083
- import { describe, expect, it } from 'vitest';
3084
- import { CodexTranscript } from '../components/CodexTranscript.js';
3085
- import { loadFixture } from './loadFixture.js';
3086
-
3087
- const FIXTURES = ['short-chat', 'tool-heavy', 'mcp-flow', 'failed-turn', 'aborted-turn', 'unknown-types'];
3088
-
3089
- describe('replay integration', () => {
3090
- for (const name of FIXTURES) {
3091
- it(`renders ${name} without throwing`, () => {
3092
- const events = loadFixture(name);
3093
- expect(() => render(<CodexTranscript events={events} disableSmoothStream />)).not.toThrow();
3094
- });
3095
- }
3096
-
3097
- it('short-chat shows both user and assistant text', () => {
3098
- const events = loadFixture('short-chat');
3099
- const { getByText } = render(<CodexTranscript events={events} disableSmoothStream />);
3100
- expect(getByText('Hello, who are you?')).toBeInTheDocument();
3101
- expect(getByText("I'm Codex, your coding assistant.")).toBeInTheDocument();
3102
- });
3103
-
3104
- it('failed-turn StatusBar shows failed', () => {
3105
- const events = loadFixture('failed-turn');
3106
- const { getByRole } = render(<CodexTranscript events={events} disableSmoothStream />);
3107
- expect(getByRole('status').getAttribute('data-status')).toBe('failed');
3108
- });
3109
-
3110
- it('aborted-turn StatusBar shows stopped', () => {
3111
- const events = loadFixture('aborted-turn');
3112
- const { getByRole } = render(<CodexTranscript events={events} disableSmoothStream />);
3113
- expect(getByRole('status').getAttribute('data-status')).toBe('stopped');
3114
- });
3115
-
3116
- it('unknown-types renders RawEventBlock for unknown event', () => {
3117
- const events = loadFixture('unknown-types');
3118
- const { container } = render(<CodexTranscript events={events} disableSmoothStream />);
3119
- expect(container.textContent).toMatch(/未知事件/);
3120
- });
3121
- });
3122
- ```
3123
-
3124
- - [ ] **Step 10: Run integration tests**
3125
-
3126
- ```bash
3127
- pnpm test -- src/integration/replay.test.tsx
3128
- ```
3129
-
3130
- Expected: 10 PASS (6 smoke + 4 specific assertions).
3131
-
3132
- - [ ] **Step 11: Commit**
3133
-
3134
- ```bash
3135
- git add fixtures/ src/integration/
3136
- git commit -m "test(integration): replay 6 fixtures through CodexTranscript"
3137
- ```
3138
-
3139
- ---
3140
-
3141
- ## Task 21: dev SPA (vite, fixture browser)
3142
-
3143
- **Files:**
3144
- - Create: `dev/index.html`, `dev/vite.config.ts`, `dev/src/main.tsx`, `dev/src/App.tsx`, `dev/src/loadFixturesBrowser.ts`
3145
-
3146
- - [ ] **Step 1: Write `dev/index.html`**
3147
-
3148
- ```html
3149
- <!doctype html>
3150
- <html lang="zh">
3151
- <head>
3152
- <meta charset="UTF-8" />
3153
- <meta name="viewport" content="width=device-width, initial-scale=1" />
3154
- <title>codexview dev</title>
3155
- <style>
3156
- html, body, #root { height: 100%; margin: 0; }
3157
- body { font-family: system-ui, sans-serif; background: #f6f8fa; }
3158
- </style>
3159
- </head>
3160
- <body>
3161
- <div id="root"></div>
3162
- <script type="module" src="./src/main.tsx"></script>
3163
- </body>
3164
- </html>
3165
- ```
3166
-
3167
- - [ ] **Step 2: Write `dev/vite.config.ts`**
3168
-
3169
- ```ts
3170
- import { defineConfig } from 'vite';
3171
- import react from '@vitejs/plugin-react';
3172
- import { resolve } from 'node:path';
3173
-
3174
- export default defineConfig({
3175
- root: resolve(__dirname),
3176
- plugins: [react()],
3177
- server: { port: 5180, open: true },
3178
- });
3179
- ```
3180
-
3181
- - [ ] **Step 3: Write `dev/src/loadFixturesBrowser.ts`**
3182
-
3183
- ```ts
3184
- import type { ChatStreamEvent } from '../../src/types/events.js';
3185
-
3186
- const modules = import.meta.glob('../../fixtures/*.jsonl', { as: 'raw', eager: true });
3187
-
3188
- export interface FixtureEntry { name: string; events: ChatStreamEvent[] }
3189
-
3190
- export const FIXTURES: FixtureEntry[] = Object.entries(modules)
3191
- .map(([path, raw]) => {
3192
- const name = path.split('/').pop()!.replace('.jsonl', '');
3193
- const events = (raw as string)
3194
- .split('\n')
3195
- .filter((l) => l.trim().length > 0)
3196
- .map((l) => JSON.parse(l) as ChatStreamEvent);
3197
- return { name, events };
3198
- })
3199
- .sort((a, b) => a.name.localeCompare(b.name));
3200
- ```
3201
-
3202
- - [ ] **Step 4: Write `dev/src/App.tsx`**
3203
-
3204
- ```tsx
3205
- import { useState } from 'react';
3206
- import { CodexTranscript } from '../../src/components/CodexTranscript.js';
3207
- import { FIXTURES } from './loadFixturesBrowser.js';
3208
-
3209
- export function App(): JSX.Element {
3210
- const [name, setName] = useState(FIXTURES[0]?.name ?? '');
3211
- const current = FIXTURES.find((f) => f.name === name);
3212
- return (
3213
- <div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12, height: '100%' }}>
3214
- <header style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
3215
- <strong>codexview</strong>
3216
- <span style={{ color: '#6e7781' }}>fixture:</span>
3217
- <select value={name} onChange={(e) => setName(e.target.value)}>
3218
- {FIXTURES.map((f) => <option key={f.name}>{f.name}</option>)}
3219
- </select>
3220
- </header>
3221
- <main style={{ flex: 1, minHeight: 0, overflow: 'auto', background: '#fff', borderRadius: 12, border: '1px solid #d0d7de' }}>
3222
- {current && <CodexTranscript events={current.events} />}
3223
- </main>
3224
- </div>
3225
- );
3226
- }
3227
- ```
3228
-
3229
- - [ ] **Step 5: Write `dev/src/main.tsx`**
3230
-
3231
- ```tsx
3232
- import { StrictMode } from 'react';
3233
- import { createRoot } from 'react-dom/client';
3234
- import { App } from './App.js';
3235
- import '../../src/styles/tokens.css';
3236
-
3237
- createRoot(document.getElementById('root')!).render(
3238
- <StrictMode>
3239
- <App />
3240
- </StrictMode>,
3241
- );
3242
- ```
3243
-
3244
- - [ ] **Step 6: Verify dev server boots**
3245
-
3246
- ```bash
3247
- pnpm dev
3248
- ```
3249
-
3250
- Open the browser at `http://localhost:5180`. Switch through every fixture in the dropdown. Observe: each fixture renders without console errors. Press Ctrl+C to stop.
3251
-
3252
- - [ ] **Step 7: Commit**
3253
-
3254
- ```bash
3255
- git add dev/
3256
- git commit -m "chore(dev): vite-based fixture browser"
3257
- ```
3258
-
3259
- ---
3260
-
3261
- ## Task 22: README + docs/api.md
3262
-
3263
- **Files:**
3264
- - Modify: `README.md`
3265
- - Create: `docs/api.md`
3266
-
3267
- - [ ] **Step 1: Replace `README.md`**
3268
-
3269
- ```markdown
3270
- # codexview
3271
-
3272
- React components for rendering OpenAI Codex CLI chat streams. Designed for agentweb but framework-agnostic for any host that produces compatible `ChatStreamEvent` sequences.
3273
-
3274
- ## Install
3275
-
3276
- ```bash
3277
- pnpm add codexview lucide-react react react-dom
3278
- ```
3279
-
3280
- Import the stylesheet once in your app entrypoint:
3281
-
3282
- ```ts
3283
- import 'codexview/styles.css';
3284
- ```
3285
-
3286
- ## 60-second quick start
3287
-
3288
- ```tsx
3289
- import { CodexTranscript, type ChatStreamEvent } from 'codexview';
3290
- import 'codexview/styles.css';
3291
-
3292
- const events: ChatStreamEvent[] = [
3293
- { type: 'thread_started', threadId: 'T', at: Date.now() },
3294
- { type: 'turn_started', turnId: 'A', at: Date.now() },
3295
- { type: 'user_message', turnId: 'A', itemId: 'u1', text: 'Hello!', at: Date.now() },
3296
- { type: 'agent_message', turnId: 'A', itemId: 'a1', text: 'Hi.', partial: false, at: Date.now() },
3297
- { type: 'turn_completed', turnId: 'A', at: Date.now() },
3298
- ];
3299
-
3300
- export function App() {
3301
- return <CodexTranscript events={events} />;
3302
- }
3303
- ```
3304
-
3305
- `events` is a plain array. Append new events as they arrive (typically via SSE) and pass the same array reference back; CodexView reduces incrementally.
3306
-
3307
- ## Status
3308
-
3309
- Session-level status (`idle | working | completed | stopped | failed`) is inferred automatically. Override via the `status` prop (e.g. when SSE drops, set `status="stopped"`).
3310
-
3311
- ## Customizing
3312
-
3313
- - Swap any block via `components` prop: `<CodexTranscript components={{ ToolCallBlock: MyToolUI }} />`
3314
- - Theme via CSS variables — see [docs/styling.md](docs/styling.md) for the full list.
3315
-
3316
- ## More docs
3317
-
3318
- - [docs/api.md](docs/api.md) — every public API
3319
- - [docs/events.md](docs/events.md) — `ChatStreamEvent` contract
3320
- - [docs/styling.md](docs/styling.md) — CSS variables
3321
- - [docs/integration-agentweb.md](docs/integration-agentweb.md) — drop-in for agentweb
3322
-
3323
- ## License
3324
-
3325
- MIT.
3326
- ```
3327
-
3328
- - [ ] **Step 2: Write `docs/api.md`**
3329
-
3330
- ```markdown
3331
- # API Reference
3332
-
3333
- All exports from `codexview`. Import via `import { ... } from 'codexview'`.
3334
-
3335
- ## Components
3336
-
3337
- ### `<CodexTranscript>`
3338
-
3339
- Top-level transcript renderer.
3340
-
3341
- ```tsx
3342
- <CodexTranscript
3343
- events={events}
3344
- status?={status}
3345
- error?={{ message, details? }}
3346
- className?={string}
3347
- maxItems?={number}
3348
- emptyState?={ReactNode}
3349
- onItemClick?={(id) => void}
3350
- components?={Partial<CodexTranscriptComponents>}
3351
- disableSmoothStream?={boolean}
3352
- onInternalError?={(err, event?) => void}
3353
- />
3354
- ```
3355
-
3356
- | Prop | Default | Notes |
3357
- |------|---------|-------|
3358
- | `events` | required | Append-only is most efficient; full replace also works. Reference equality skips re-reduce. |
3359
- | `status` | inferred | When provided, fully replaces `inferStatus(model)`. |
3360
- | `error` | undefined | Shown inside `StatusBar` when status is `failed` or `stopped`. |
3361
- | `className` | undefined | Appended to `.codexview-root` class list. |
3362
- | `maxItems` | unlimited | Trims oldest items, shows "已省略最早的 X 条" hint at the top. |
3363
- | `emptyState` | `'暂无对话'` | Rendered when `events` is empty. |
3364
- | `onItemClick` | undefined | Receives `item.id` of the clicked item. |
3365
- | `components` | builtin | Replace any subcomponent (e.g. `{ ToolCallBlock: MyTool }`). |
3366
- | `disableSmoothStream` | `false` | Disables the typewriter effect for `assistant_text` and `reasoning`. |
3367
- | `onInternalError` | undefined | Called when reducer or item render throws; component falls back without crashing. |
3368
-
3369
- #### Minimal example
3370
-
3371
- ```tsx
3372
- const events = useAtomValue(streamingAtomFamily(sessionId));
3373
- return <CodexTranscript events={events.list} />;
3374
- ```
3375
-
3376
- ### `<StatusBar>`
3377
-
3378
- ```tsx
3379
- <StatusBar status={status} label?={string} error?={{ message, details? }} />
3380
- ```
3381
-
3382
- Returns `null` when `status === 'idle'`.
3383
-
3384
- ### `<TurnContainer>`
3385
-
3386
- ```tsx
3387
- <TurnContainer turn={turn}>{children}</TurnContainer>
3388
- ```
3389
-
3390
- ### `<MessageBubble>`
3391
-
3392
- ```tsx
3393
- <MessageBubble item={userOrAssistantItem} smoothStream?={boolean} />
3394
- ```
3395
-
3396
- ### `<ReasoningBlock>`
3397
-
3398
- ```tsx
3399
- <ReasoningBlock item={reasoningItem} defaultOpen?={boolean} smoothStream?={boolean} />
3400
- ```
3401
-
3402
- ### `<ToolCallBlock>`
3403
-
3404
- ```tsx
3405
- <ToolCallBlock item={toolCallItem} />
3406
- ```
3407
-
3408
- Renders args inline; result inline if small, collapsed in `<details>` if long (`>500` chars, `>4` lines, or `>3` JSON depth).
3409
-
3410
- ### `<ExecBlock>`
3411
-
3412
- ```tsx
3413
- <ExecBlock item={execItem} />
3414
- ```
3415
-
3416
- Shimmer bar shown while `status === 'running'`. stdout/stderr collapsed beyond size threshold.
3417
-
3418
- ### `<SearchBlock>`
3419
-
3420
- ```tsx
3421
- <SearchBlock item={searchItem} initialVisible?={number} />
3422
- ```
3423
-
3424
- Shows first 3 results by default; "展开剩余 N 条" reveals the rest.
3425
-
3426
- ### `<PatchBlock>`
3427
-
3428
- ```tsx
3429
- <PatchBlock item={patchItem} />
3430
- ```
3431
-
3432
- Each file is collapsed; expanding shows diff with git-style coloring.
3433
-
3434
- ### `<RawEventBlock>`
3435
-
3436
- ```tsx
3437
- <RawEventBlock item={rawItem} />
3438
- ```
3439
-
3440
- Used as fallback for unknown event types.
3441
-
3442
- ### `<ItemErrorBoundary>`
3443
-
3444
- ```tsx
3445
- <ItemErrorBoundary fallback?={ReactNode} onError?={(err, info) => void}>
3446
- {children}
3447
- </ItemErrorBoundary>
3448
- ```
3449
-
3450
- ## Hooks
3451
-
3452
- ### `useCodexTranscript(events, options?)`
3453
-
3454
- ```ts
3455
- const { model, status } = useCodexTranscript(events, { status?, onInternalError? });
3456
- ```
3457
-
3458
- Internally caches the last reduced model and uses prefix detection for incremental reduction.
3459
-
3460
- ### `useSmoothStream(text, options?)`
3461
-
3462
- ```ts
3463
- const display = useSmoothStream(fullText, { enabled?, charsPerFrame?, minDelayMs? });
3464
- ```
3465
-
3466
- Returns the substring revealed so far. Resets when input shrinks. Returns full text immediately if `enabled === false` or `prefers-reduced-motion: reduce`.
3467
-
3468
- ## Pure functions
3469
-
3470
- ### `reduceTranscript(prev, event) => next`
3471
-
3472
- Pure reducer. Use directly to drive your own state container.
3473
-
3474
- ### `inferStatus(model) => TranscriptStatus`
3475
-
3476
- Returns `'idle' | 'working' | 'completed' | 'stopped' | 'failed'`.
3477
-
3478
- ### `EMPTY_MODEL`
3479
-
3480
- Frozen initial `TranscriptModel`. Always safe to start reducing from this.
3481
-
3482
- ## Types
3483
-
3484
- `ChatStreamEvent`, `ChatStreamEventType`, `TokenUsage`, `SearchResult`, `PatchFile`, `TranscriptModel`, `TurnView`, `ItemView`, `ItemKind`, `ItemStatus`, `TranscriptStatus`, `CodexTranscriptComponents`, `UseCodexTranscriptOptions`, `UseSmoothStreamOptions`, plus per-component prop types (`StatusBarProps`, etc.).
3485
-
3486
- See [docs/events.md](events.md) for the full `ChatStreamEvent` shape.
3487
- ```
3488
-
3489
- - [ ] **Step 3: Commit**
3490
-
3491
- ```bash
3492
- git add README.md docs/api.md
3493
- git commit -m "docs: README quick start + full API reference"
3494
- ```
3495
-
3496
- ---
3497
-
3498
- ## Task 23: docs/events.md + docs/styling.md
3499
-
3500
- **Files:**
3501
- - Create: `docs/events.md`, `docs/styling.md`
3502
-
3503
- - [ ] **Step 1: Write `docs/events.md`**
3504
-
3505
- ```markdown
3506
- # Event contract
3507
-
3508
- CodexView consumes a discriminated union `ChatStreamEvent`. The host (e.g. agentweb backend) is expected to emit events that match these shapes. Unknown event types are not errors — they fall through to `kind: 'raw'` and render via `RawEventBlock`.
3509
-
3510
- All events carry `at: number` (epoch ms).
3511
-
3512
- ## Lifecycle
3513
-
3514
- | Type | Required fields | Reducer effect |
3515
- |------|-----------------|----------------|
3516
- | `thread_started` | `threadId` | sets `model.threadId` |
3517
- | `turn_started` | `turnId` | appends new `TurnView { status: 'running' }` |
3518
- | `turn_completed` | `turnId`, optional `usage` | turn → `completed`; flips unfinished items → `completed` |
3519
- | `turn_failed` | `turnId`, `error: { message, code? }` | turn → `failed`; flips unfinished items → `failed` |
3520
- | `turn_aborted` | `turnId`, optional `reason` | turn → `aborted`; flips unfinished items → `stopped` |
3521
-
3522
- ## Messages
3523
-
3524
- | Type | Required fields | ItemView produced |
3525
- |------|-----------------|-------------------|
3526
- | `user_message` | `turnId`, `itemId`, `text` | `kind: 'user_message'`, status `completed` |
3527
- | `agent_message` | `turnId`, `itemId`, `text`, `partial` | `kind: 'assistant_text'`; same `itemId` updates in place; `partial: false` flips to `completed` |
3528
- | `reasoning` | same as `agent_message` | `kind: 'reasoning'`; **never merged** with assistant_text |
3529
-
3530
- ## Tool calls (paired by `callId`)
3531
-
3532
- | Type | Required fields | Reducer effect |
3533
- |------|-----------------|----------------|
3534
- | `function_call` | `turnId`, `callId`, `name`, `args` | appends `tool_call`, status `pending` |
3535
- | `function_call_output` | `turnId`, `callId`, optional `output` or `error` | finds matching `tool_call`, sets `result` or `error`, flips status |
3536
- | `mcp_tool_call` | `turnId`, `callId`, `server`, `name`, `args` | same as `function_call` plus `server` |
3537
- | `mcp_tool_call_output` | `turnId`, `callId`, optional `output` or `error` | same as `function_call_output` |
3538
-
3539
- ## Shell exec
3540
-
3541
- | Type | Required fields | Reducer effect |
3542
- |------|-----------------|----------------|
3543
- | `exec_command_begin` | `turnId`, `callId`, `command` | appends `exec`, status `running` |
3544
- | `exec_command_end` | `turnId`, `callId`, `exit`, `stdout`, `stderr`, `durationMs` | finds matching `exec`, fills outputs, status `completed` if `exit === 0` else `failed` |
3545
-
3546
- ## Web search
3547
-
3548
- | Type | Required fields | Reducer effect |
3549
- |------|-----------------|----------------|
3550
- | `web_search_call` | `turnId`, `callId`, `query` | appends `search`, status `pending` |
3551
- | `web_search_end` | `turnId`, `callId`, `results: SearchResult[]` | finds matching `search`, fills results, status `completed` |
3552
-
3553
- ## Patch apply
3554
-
3555
- | Type | Required fields | Reducer effect |
3556
- |------|-----------------|----------------|
3557
- | `patch_apply_end` | `turnId`, `callId`, `files: PatchFile[]`, `ok: boolean` | appends `patch`, status `completed` if `ok` else `failed` |
3558
-
3559
- ## Fallback
3560
-
3561
- | Type | Required fields | Reducer effect |
3562
- |------|-----------------|----------------|
3563
- | `raw` | optional `turnId`, `payload: unknown` | if `turnId` present, appends `raw` ItemView; else only updates `lastEventAt` |
3564
-
3565
- ## Invariants
3566
-
3567
- 1. Reducer never throws on any input.
3568
- 2. Bulk `events.reduce(reduceTranscript, EMPTY_MODEL)` equals incremental reduce of the same series.
3569
- 3. Reducer is a pure function — no global state, no I/O.
3570
- 4. Unknown payload `type` is preserved verbatim in the produced `raw` ItemView.
3571
- ```
3572
-
3573
- - [ ] **Step 2: Write `docs/styling.md`**
3574
-
3575
- ```markdown
3576
- # Styling
3577
-
3578
- CodexView ships its own styles (`codexview/styles.css`) plus a `.codexview-root` reset to isolate from host CSS. All visual tokens are CSS variables you can override.
3579
-
3580
- ## Loading
3581
-
3582
- ```ts
3583
- import 'codexview/styles.css';
3584
- ```
3585
-
3586
- ## Variable reference
3587
-
3588
- Set these on `.codexview-root` (or any ancestor) to theme.
3589
-
3590
- ### Layout
3591
-
3592
- | Variable | Default | Notes |
3593
- |----------|---------|-------|
3594
- | `--cv-font-family` | system-ui stack | |
3595
- | `--cv-font-mono` | ui-monospace stack | |
3596
- | `--cv-font-size` | `14px` | base text size |
3597
- | `--cv-line-height` | `1.55` | |
3598
- | `--cv-radius` | `12px` | bubble radius |
3599
- | `--cv-radius-sm` | `8px` | block radius |
3600
- | `--cv-spacing-xs/-sm/-md/-lg` | 4/8/12/16 px | spacing scale |
3601
-
3602
- ### Colors
3603
-
3604
- | Variable | Default (light) | Notes |
3605
- |----------|-----------------|-------|
3606
- | `--cv-text` | `#1f2328` | primary text |
3607
- | `--cv-text-muted` | `#6e7781` | reasoning, captions |
3608
- | `--cv-text-inverse` | `#ffffff` | text on user bubble |
3609
- | `--cv-bg` | `#ffffff` | transcript background |
3610
- | `--cv-bg-raised` | `#f6f8fa` | StatusBar, tool block surface |
3611
- | `--cv-bg-user-bubble` | `#2f6feb` | user bubble |
3612
- | `--cv-bg-assistant-bubble` | `#f6f8fa` | assistant bubble |
3613
- | `--cv-bg-code` | `#0d1117` | exec / code background |
3614
- | `--cv-fg-code` | `#e6edf3` | code foreground |
3615
- | `--cv-border` | `#d0d7de` | dividers |
3616
- | `--cv-axis-color` | `#d0d7de` | turn timeline axis |
3617
- | `--cv-shimmer-color` | `rgba(31,35,40,0.08)` | exec shimmer |
3618
-
3619
- ### Status colors
3620
-
3621
- | Variable | Default | Status |
3622
- |----------|---------|--------|
3623
- | `--cv-status-pending` | `#6e7781` | gray |
3624
- | `--cv-status-running` | `#2f6feb` | blue |
3625
- | `--cv-status-completed` | `#1a7f37` | green |
3626
- | `--cv-status-failed` | `#cf222e` | red |
3627
- | `--cv-status-stopped` | `#6e7781` | gray |
3628
-
3629
- ### Diff colors
3630
-
3631
- | Variable | Default |
3632
- |----------|---------|
3633
- | `--cv-diff-add-bg` | `#ddf4e4` |
3634
- | `--cv-diff-del-bg` | `#ffebe9` |
3635
-
3636
- ## Dark theme example
3637
-
3638
- ```css
3639
- .dark .codexview-root {
3640
- --cv-text: #e6edf3;
3641
- --cv-text-muted: #8b949e;
3642
- --cv-bg: #0d1117;
3643
- --cv-bg-raised: #161b22;
3644
- --cv-bg-assistant-bubble: #161b22;
3645
- --cv-bg-user-bubble: #1f6feb;
3646
- --cv-border: #30363d;
3647
- --cv-axis-color: #30363d;
3648
- --cv-shimmer-color: rgba(255,255,255,0.05);
3649
- --cv-bg-code: #010409;
3650
- --cv-fg-code: #c9d1d9;
3651
- }
3652
- ```
3653
-
3654
- ## Reduced motion
3655
-
3656
- All animations (pulse, shimmer, blink caret, smooth stream) honor `prefers-reduced-motion: reduce` and degrade to static states automatically.
3657
- ```
3658
-
3659
- - [ ] **Step 3: Commit**
3660
-
3661
- ```bash
3662
- git add docs/events.md docs/styling.md
3663
- git commit -m "docs: events contract + CSS variables reference"
3664
- ```
3665
-
3666
- ---
3667
-
3668
- ## Task 24: docs/integration-agentweb.md + docs/changelog.md
3669
-
3670
- **Files:**
3671
- - Create: `docs/integration-agentweb.md`, `docs/changelog.md`
3672
-
3673
- - [ ] **Step 1: Write `docs/integration-agentweb.md`**
3674
-
3675
- ```markdown
3676
- # Integrating CodexView into agentweb
3677
-
3678
- This is a drop-in replacement for `frontend/src/codex/components/MessageBubble.tsx`, `StreamingBubble.tsx`, and `ToolUseBlock.tsx`. The agentweb backend (`backend/src/codex/eventMap.ts`) already produces a normalized event stream that maps 1:1 onto `ChatStreamEvent`.
3679
-
3680
- ## Step 1 — install (development)
3681
-
3682
- From the agentweb repo root:
3683
-
3684
- ```bash
3685
- pnpm --filter frontend add file:../CodexView
3686
- pnpm --filter frontend add lucide-react
3687
- ```
3688
-
3689
- (Once `codexview` is published to a registry, replace `file:../CodexView` with the version range.)
3690
-
3691
- ## Step 2 — load styles + bridge tokens
3692
-
3693
- In `frontend/src/main.tsx` (or another global entry):
3694
-
3695
- ```ts
3696
- import 'codexview/styles.css';
3697
- ```
3698
-
3699
- In `frontend/src/codex/styles/tokens.css` (append):
3700
-
3701
- ```css
3702
- .aw-codex-transcript {
3703
- --cv-bg-user-bubble: var(--aw-bg-bubble-user);
3704
- --cv-bg-assistant-bubble: var(--aw-bg-bubble-bot);
3705
- --cv-text: var(--aw-text-primary);
3706
- --cv-axis-color: var(--aw-border-subtle);
3707
- --cv-bg-raised: var(--aw-bg-raised);
3708
- }
3709
- ```
3710
-
3711
- (Match the variable list in `docs/styling.md` against agentweb's tokens.)
3712
-
3713
- ## Step 3 — replace ChatThread internals
3714
-
3715
- `frontend/src/codex/components/ChatThread.tsx`:
3716
-
3717
- ```tsx
3718
- import { useAtomValue } from 'jotai';
3719
- import { CodexTranscript } from 'codexview';
3720
- import { streamingAtomFamily } from '../atoms/streaming';
3721
-
3722
- export function ChatThread({ sessionId }: { sessionId: string }) {
3723
- const stream = useAtomValue(streamingAtomFamily(sessionId));
3724
- return (
3725
- <CodexTranscript
3726
- events={stream.list}
3727
- status={stream.connected ? undefined : 'stopped'}
3728
- className="aw-codex-transcript"
3729
- />
3730
- );
3731
- }
3732
- ```
3733
-
3734
- Adjust the property names (`stream.list`, `stream.connected`) to match the actual `streamingAtomFamily` shape.
3735
-
3736
- ## Step 4 — clean up + handle approval
3737
-
3738
- Delete from `frontend/src/codex/components/`:
3739
-
3740
- - `MessageBubble.tsx`
3741
- - `StreamingBubble.tsx`
3742
- - `ToolUseBlock.tsx`
3743
-
3744
- **Keep** approval logic. CodexView v0.1 deliberately does **not** ship an approval-bubble component (see spec §11). If `StreamingBubble.tsx` carried that responsibility, extract the approval portion into a standalone `ApprovalBubble.tsx` component within agentweb and render it next to `<CodexTranscript>` (e.g. as a sibling overlay), feeding it the same approval events.
3745
-
3746
- ## Verify
3747
-
3748
- ```bash
3749
- pnpm --filter frontend test
3750
- pnpm --filter frontend dev
3751
- ```
3752
-
3753
- In the browser:
3754
-
3755
- 1. Start a Codex session.
3756
- 2. Confirm streaming text appears with smooth typewriter effect.
3757
- 3. Trigger a tool call — confirm collapsible result.
3758
- 4. Trigger an exec — confirm shimmer while running.
3759
- 5. Disconnect the network — confirm StatusBar shows "已停止" once you set `status="stopped"`.
3760
-
3761
- ## Rollback
3762
-
3763
- ```bash
3764
- git revert <integration-commit-sha>
3765
- ```
3766
-
3767
- Backend `ChatStreamEvent` shape and SSE endpoints do not change, so revert is purely frontend.
3768
-
3769
- ## Type-safety guard
3770
-
3771
- Add to `frontend/src/codex/types/eventCheck.ts`:
3772
-
3773
- ```ts
3774
- import type { ChatStreamEvent as CV } from 'codexview';
3775
- import type { ChatStreamEvent as AW } from '../../../../backend/src/codex/eventMap';
3776
-
3777
- // Will fail to compile if shapes drift.
3778
- type _AssertEqual<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : never;
3779
- type _Check = _AssertEqual<CV, AW>;
3780
- ```
3781
-
3782
- When the agentweb backend adds an event type, this assertion fails until codexview is updated.
3783
- ```
3784
-
3785
- - [ ] **Step 2: Write `docs/changelog.md`**
3786
-
3787
- ```markdown
3788
- # Changelog
3789
-
3790
- All notable changes documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3791
-
3792
- ## [0.1.0] — unreleased
3793
-
3794
- ### Added
3795
-
3796
- - `<CodexTranscript>` main component (consumes `ChatStreamEvent[]`)
3797
- - Subcomponents: `StatusBar`, `TurnContainer`, `MessageBubble`, `ReasoningBlock`, `ToolCallBlock`, `ExecBlock`, `SearchBlock`, `PatchBlock`, `RawEventBlock`, `ItemErrorBoundary`
3798
- - Hooks: `useCodexTranscript`, `useSmoothStream`
3799
- - Pure functions: `reduceTranscript`, `inferStatus`, `EMPTY_MODEL`
3800
- - 8 supported event item kinds + raw fallback for unknown types
3801
- - 5-state item status machine, 4-state turn status machine, 5-state session status
3802
- - CSS Modules styling with overridable CSS variables
3803
- - `lucide-react` icon system as peerDependency
3804
- - `prefers-reduced-motion` honored by all animations
3805
- - 6 fixtures + replay integration tests
3806
- - Docs: API reference, event contract, styling, agentweb integration guide
3807
- ```
3808
-
3809
- - [ ] **Step 3: Commit**
3810
-
3811
- ```bash
3812
- git add docs/integration-agentweb.md docs/changelog.md
3813
- git commit -m "docs: agentweb integration guide + changelog"
3814
- ```
3815
-
3816
- ---
3817
-
3818
- ## Task 25: Final build, full test sweep, agentweb link verification
3819
-
3820
- **Files:**
3821
- - None new; final validation.
3822
-
3823
- - [ ] **Step 1: Run full typecheck + tests + build**
3824
-
3825
- ```bash
3826
- cd /Volumes/MaxSSD1/MigratedHome/maxazure/projects/CodexView
3827
- pnpm typecheck && pnpm test && pnpm build
3828
- ```
3829
-
3830
- Expected: all pass; `dist/` contains `index.js`, `index.d.ts`, sourcemaps, plus emitted CSS file(s).
3831
-
3832
- - [ ] **Step 2: Verify dist exports work via Node**
3833
-
3834
- ```bash
3835
- node -e "import('./dist/index.js').then(m => console.log(Object.keys(m).sort()))"
3836
- ```
3837
-
3838
- Expected: prints sorted list including `CodexTranscript`, `EMPTY_MODEL`, `MessageBubble`, `StatusBar`, `VERSION`, `inferStatus`, `reduceTranscript`, `useCodexTranscript`, `useSmoothStream`, plus the rest.
3839
-
3840
- - [ ] **Step 3: Sanity check package metadata**
3841
-
3842
- ```bash
3843
- node -e "console.log(JSON.parse(require('node:fs').readFileSync('package.json'))).exports"
3844
- ```
3845
-
3846
- Expected: `exports['.'].import` resolves to `./dist/index.js` and `exports['./styles.css']` is set.
3847
-
3848
- - [ ] **Step 4: Local-link into agentweb (manual smoke test)**
3849
-
3850
- ```bash
3851
- cd /Users/maxazure/Projects/agentweb
3852
- pnpm --filter frontend add file:../../Volumes/MaxSSD1/MigratedHome/maxazure/projects/CodexView
3853
- pnpm --filter frontend dev
3854
- ```
3855
-
3856
- Open `http://localhost:5173` (or whichever port agentweb's vite reports). Navigate to a Codex chat session. Confirm: messages render through CodexTranscript, no console errors. (Replacing the actual `ChatThread.tsx` rendering is its own agentweb PR — at this stage just verify the package loads.)
3857
-
3858
- Press Ctrl+C; revert the link to avoid polluting the agentweb pnpm-lock:
3859
-
3860
- ```bash
3861
- cd /Users/maxazure/Projects/agentweb
3862
- git checkout -- frontend/package.json pnpm-lock.yaml
3863
- pnpm install
3864
- ```
3865
-
3866
- - [ ] **Step 5: Tag the release commit**
3867
-
3868
- ```bash
3869
- cd /Volumes/MaxSSD1/MigratedHome/maxazure/projects/CodexView
3870
- git tag v0.1.0 -m "codexview 0.1.0 — initial release"
3871
- git log --oneline | head -10
3872
- ```
3873
-
3874
- - [ ] **Step 6: Final summary commit (if any pending docs/changes)**
3875
-
3876
- ```bash
3877
- git status
3878
- # If clean, skip. Otherwise: git add -A && git commit -m "chore: 0.1.0 release prep"
3879
- ```
3880
-
3881
- Done. Report:
3882
-
3883
- - All tasks ✅
3884
- - All tests passing
3885
- - `dist/` built
3886
- - agentweb smoke test passed
3887
- - Tag v0.1.0 created
3888
-
3889
- The actual replacement of agentweb's `ChatThread.tsx` to use `<CodexTranscript>` is a separate follow-up PR in the agentweb repo, guided by [docs/integration-agentweb.md](../../docs/integration-agentweb.md).
3890
-
3891
- ---
3892
-
3893
- ## Self-review checklist (run before declaring plan ready)
3894
-
3895
- 1. **Spec coverage** — every section of [the spec](../specs/2026-05-15-codexview-design.md) maps to at least one task. Verified: §2 → Tasks 1-2; §3 → Tasks 19, 22; §4 → Tasks 3-9; §5 → Task 10; §6 → Tasks 13-19; §7 → Tasks 11, 14, 19; §8 → Tasks 5-12, 20; §9 → Tasks 22-24; §10 → Tasks 24-25; §11 → out-of-scope captured in task notes; §13 → Task 25.
3896
- 2. **No placeholders** — every step includes either complete code or an exact command. Verified.
3897
- 3. **Type consistency** — all referenced types/functions (`ChatStreamEvent`, `ItemView`, `reduceTranscript`, `inferStatus`, `useCodexTranscript`, `useSmoothStream`, etc.) match between definition and usage.
3898
- 4. **Frequent commits** — every task ends with a commit; granularity ~1 commit per logical unit.
3899
- 5. **TDD** — reducer + hooks + components all start with failing tests before implementation.
3900
-
3901
-
3902
-
3903
-