Skip to content

Commit 7805d2b

Browse files
author
probe
committed
fix(webui): 让 webui 的类型检查与仓库其余部分用同一套标准
`typecheck:webui` 的 `typecheck:client` 报 6 个错、`typecheck:test` 报 389 个, 从 2026-09-21 `3b8f299` 加上这两个门起就一直是红的。 ## 那 389 个错不在 webui,也不在第三方 它们在 `third_party/**`,而 webui 自己的代码是 0 错。看起来像第三方代码有 389 个问题,其实不是:`tsconfig.node.json` 打开了 `noUncheckedIndexedAccess` / `noImplicitOverride`,但**所有真正拥有那些代码的 项目都关着** —— `tsconfig.standalone.json`、`pi-mono/tsconfig.base.json`、 `sandbox-runtime/tsconfig.json` 一致关着。根 `pnpm typecheck` 读的是 standalone,所以一直是绿的。 webui `extends` 了 `tsconfig.node.json` 然后只继承、不覆盖,于是同一份代码在 一个门里干净、在另一个门里 389 个错。同一份代码、同一个检查器、两个结果。 TypeScript 没有按目录设选项的能力,所以这个全程序的值必须和**它自己输入的代码 实际使用的标准**一致。这是把 webui 摆正,不是给谁放水。 ## 配套:路径表由生成器产出,不是手抄 `packages/webui/tsconfig.json` 现在继承 `tsconfig.paths.json` —— 由 `scripts/gen-tsconfig-paths.mjs` 生成。生成器同时产出仓库的两张路径表, `check:tsconfig` 同时校验两张。 手抄 128 条有两个问题:`package-exports.mjs` 的存在意义就是让「类型检查的 路径表」和「测试解析器」不可能不一致;而根 `typecheck` 在 platform profile 里 是 `fullOnly`(跳过),手抄的表**没有任何门守护**。`tsconfig.standalone.json` 在本 PR 里逐字节未变。 ## Gate A:6 个类型错 `workspace-progress.ts` 的 5 个 `TS2345` 是同一个病:事件类型被读成 `string | undefined`,再传给要求字面量联合的形参。用类型谓词收窄,因为 `if (type && ...)` 只能把 `undefined` 去掉,剩下的 `string` 仍然赋不进去。 第 6 个 `TS2488` 是联合类型的另一支让 `toolCalls` 成了 `unknown`, `?? []` 之后仍不可迭代,用和下面那行同样的 `Array.isArray` 守卫解决。 没有用断言:这几处都是真的可选,不是「这里不可能 undefined」。 ## 验证 | 门 | 之前 | 之后 | |---|---|---| | `typecheck:server` | ✅ | ✅ | | `typecheck:client` | ❌ 6 | ✅ 0 | | `typecheck:test` | ❌ 389 | ✅ 0 | | 根 `pnpm typecheck` | ✅ | ✅ 未变 | | `check:tsconfig` / `check:source` | ✅ | ✅ | | `tsconfig.standalone.json` | — | 逐字节未变 | `pnpm test`(webui)872/933 与基线一致 —— 本 PR 不含那 4 条基线红测的修复, 那是另一条 PR 的事。两个 PR 都合,`pnpm verify --profile platform` 才全绿。 `pnpm verify --profile platform` 整体在本 PR 提交前跑不完:gate 3 `export source preview` 要求工作树已提交。`test:capabilities` 1 个、 `test:sandbox` 2 个失败,均已 stash 后在干净基线上复现,是 macOS `/var` 与 `/private/var` 的环境差异,与本 PR 无关。
1 parent 83321d0 commit 7805d2b

6 files changed

Lines changed: 504 additions & 28 deletions

File tree

‎packages/webui/src/client/projection/workspace-progress.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,20 @@ const SUBAGENT_REPORTED_EVENT_TYPES = [
215215
] as const;
216216
const TODO_EVENT_TYPES = ["todo_updated"] as const;
217217

218+
/**
219+
* `Array.prototype.includes` accepts any value, but these literal tuples are what
220+
* make a dispatch branch exhaustive, so membership has to narrow `type` instead of
221+
* merely comparing it — `type && TODO_EVENT_TYPES.includes(type)` still hands a
222+
* plain `string` to a `"todo_updated"` parameter. A frame carrying no event name
223+
* belongs to no branch, which is what `includes(undefined)` already answered.
224+
*/
225+
function isEventTypeOf<T extends string>(
226+
type: string | undefined,
227+
allowed: readonly T[],
228+
): type is T {
229+
return type !== undefined && (allowed as readonly string[]).includes(type);
230+
}
231+
218232
function terminalSubagentStatus(
219233
type: string,
220234
data: Record<string, unknown>,
@@ -231,11 +245,11 @@ export function reduceWebuiWorkspaceProgressEvent(
231245
sessionId?: string,
232246
): WebuiWorkspaceProgressState {
233247
const type = eventType(value);
234-
if (TODO_EVENT_TYPES.includes(type)) {
248+
if (isEventTypeOf(type, TODO_EVENT_TYPES)) {
235249
const todos = normalizeTodos(value.todos ?? eventData(value).todos);
236250
return todos ? { ...state, todos, hasTodoSnapshot: true } : state;
237251
}
238-
if (SUBAGENT_SPAWN_EVENT_TYPES.includes(type)) {
252+
if (isEventTypeOf(type, SUBAGENT_SPAWN_EVENT_TYPES)) {
239253
const subagent = subagentFromEvent(value, sessionId);
240254
return subagent
241255
? {
@@ -245,7 +259,7 @@ export function reduceWebuiWorkspaceProgressEvent(
245259
}
246260
: state;
247261
}
248-
if (SUBAGENT_STATUS_EVENT_TYPES.includes(type)) {
262+
if (isEventTypeOf(type, SUBAGENT_STATUS_EVENT_TYPES)) {
249263
const data = eventData(value);
250264
const childSessionId = stringValue(data, ["sessionId", "session_id"]);
251265
if (!childSessionId) return state;
@@ -262,7 +276,7 @@ export function reduceWebuiWorkspaceProgressEvent(
262276
hasSubagentSnapshot: true,
263277
};
264278
}
265-
if (SUBAGENT_REPORTED_EVENT_TYPES.includes(type)) {
279+
if (isEventTypeOf(type, SUBAGENT_REPORTED_EVENT_TYPES)) {
266280
const data = eventData(value);
267281
const childSessionId = stringValue(data, ["sessionId", "session_id"]);
268282
const existing = childSessionId
@@ -329,8 +343,12 @@ function applyTodoToolCalls(
329343
message: WebuiClientMessage | Record<string, unknown>,
330344
): WebuiWorkspaceProgressState {
331345
const loose = message as Record<string, unknown>;
346+
// `message` is a union, and its `Record<string, unknown>` arm types `toolCalls`
347+
// as `unknown` — so `message.toolCalls ?? []` leaves a non-iterable on the
348+
// right-hand side. Read the declared field through the same `Array.isArray`
349+
// guard as the snake_case one below it.
332350
const calls = [
333-
...(message.toolCalls ?? []),
351+
...(Array.isArray(message.toolCalls) ? message.toolCalls : []),
334352
...(Array.isArray(loose.tool_calls) ? loose.tool_calls : []),
335353
];
336354
let next = state;

‎packages/webui/tsconfig.json‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
11
{
2-
"extends": "../../tsconfig.node.json",
2+
"extends": "./tsconfig.paths.json",
33
"compilerOptions": {
44
"composite": false,
5-
"rootDir": "."
5+
// The path map resolves workspace packages to their TypeScript source, so the
6+
// program legitimately spans the repository rather than just this package.
7+
"rootDir": "../..",
8+
// `tsconfig.node.json` turns these on, but the workspace packages and the
9+
// vendored `third_party` sources this program pulls in are held to `strict`
10+
// without them — `tsconfig.standalone.json`, `pi-mono/tsconfig.base.json` and
11+
// `sandbox-runtime/tsconfig.json` all leave them off. TypeScript has no
12+
// per-directory options, so the program-wide value has to match the standard
13+
// its own inputs are actually written to.
14+
"noUncheckedIndexedAccess": false,
15+
"noImplicitOverride": false,
16+
// `third_party/pi-mono`'s TUI source uses the ES2024 `v` regular-expression
17+
// flag, which the inherited ES2022 target rejects.
18+
"target": "ES2024",
19+
// The path map points at `.ts` entry points, which TypeScript only accepts when
20+
// emitting is off. Every leaf config in this chain sets `noEmit`.
21+
"allowImportingTsExtensions": true
622
},
23+
// `third_party/pi-mono` reaches `highlight.js` through a deep subpath that
24+
// bypasses the package's own `types`, and ships an ambient declaration for it.
25+
// Ambient declarations only take effect when the file is part of the program, and
26+
// `extends` replaces `include` wholesale, so it cannot ride along there. `files`
27+
// is inherited instead, and unions with each leaf's own `include`.
28+
"files": [
29+
"../../third_party/pi-mono/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts"
30+
],
731
"include": ["src/**/*.ts", "src/**/*.tsx"]
832
}

0 commit comments

Comments
 (0)