@codexview/react 0.1.4 → 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.
- package/README.md +5 -7
- package/dist/index.js.map +1 -1
- package/docs/changelog.md +11 -0
- package/package.json +2 -2
- package/LICENSE +0 -21
- package/docs/superpowers/plans/2026-05-15-claude-code-adapter-implementation.md +0 -2005
- package/docs/superpowers/plans/2026-05-15-codexview-implementation.md +0 -3903
- package/docs/superpowers/specs/2026-05-15-claude-code-adapter-design.md +0 -402
- package/docs/superpowers/specs/2026-05-15-codexview-design.md +0 -661
|
@@ -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
|
-
|