Files
notebook-ai-extension/HANDOVER.md
tao.chen 12ec84a7c8 docs: refresh HANDOVER for floating panel work (d412130)
Captures the state after the per-cell -> global floating panel refactor
plus the hide+show accumulation fix. Includes:
- New commit history and validation status (jest 68, pytest 73, tsc OK)
- Full file-change table for the 6 files touched this session
- OpenCodeFloatingPanel design notes (lifecycle, state, API)
- Detailed writeup of the d412130 bug + defensive DOM cleanup fix
  + the test's known limitation (mock is more lenient than real Lumino)
- Outstanding work for next session: real JupyterLab test, Playwright,
  jlpm build:prod, pre-existing lint cleanup
- Critical context: kimi/kimi-k2.7-code was rejected by Codex proxy
  throughout this session; CC fallback was used for almost all code work
2026-07-29 10:37:46 +08:00

208 lines
11 KiB
Markdown

# opencode_bridge 交接文档
## 状态速览
**分支**:`feature/floating-ai-button`(已 push,tracking 已设)
**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
**基础**:`main` @ `310d507`(上次 HANDOVER 时的版本)
**当前 HEAD**:`d412130`
**提交**:
```
d412130 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
c0eba7e feat: replace per-cell AI button with global floating panel
f9d52f7 docs: add HANDOVER.md for next session context
310d507 fix: SSE filter no longer drops content events for a different session
```
**验证**:✅ Jest 68/68 · ✅ pytest 73/73 · ✅ tsc exit 0 · ⚠️ 2 个 pre-existing lint 错(`FlatProvider`/`BlockEntry` 命名约定,在 `inline_prompt.ts` 里,我没引入)
**PR**:未提。Gitea 链接:
```
http://101.43.40.124:3000/tao.chen/notebook-ai-extension/pulls/new/feature/floating-ai-button
```
---
## 本次会话做了什么
**架构变更**:把 per-cell 工具栏 AI 按钮(原 `OpenCodeCellActions`)整个换成全局右下角悬浮按钮 + 弹出 panel。**不是并存**,是完全替换 — per-cell 路径已删除。
```
Before After
───────────────────────────────── ─────────────────────────────────
Cell 工具栏 → per-cell 按钮 document.body 右下角固定按钮(🪄)
↓ click ↓ click
内嵌 prompt panel(挂到 cell) 悬浮 prompt panel(挂在按钮上方)
↓ ↓
当前 cell 是 owner 从 app.shell.currentWidget 取 active notebook
↓ ↓
"📋 插入单元格内容" 用 this._cell 通过 getActiveCell() 回调拿 active cell
```
---
## 关键改动文件
| 文件 | 改动 |
|------|------|
| `src/components/opencode_floating_panel.ts` | **新增** — 全局按钮 + panel 容器(详见下文) |
| `src/components/opencode_cell_actions.ts` | **删除** — 逻辑全部搬到 floating panel |
| `src/components/opencode_inline_prompt.ts` | 解耦 CodeCell。新增 `getActiveCell?: () => CodeCell \| null` 选项。3 处方法(`_insertCellSource` / `_insertAtCursor` / `_replaceCell`)改用回调。`setDisabled()` 在构造器末尾调一次,确保 insert button 初始 disabled 状态正确 |
| `src/index.ts` | 删 `toolbarRegistry.addFactory` + `IToolbarWidgetRegistry` optional。新建 `OpenCodeFloatingPanel` 挂到 `document.body` |
| `style/base.css` | 删 `.opencode-cell-actions` 块。新增 `.opencode-floating-root` / `.opencode-floating-button` / `.opencode-floating-panel` 样式(fixed 定位、z-index 1000、480px 宽、60vh 高) |
| `src/__tests__/opencode_floating_panel.spec.ts` | 重命名 + 重写。原 1663 行 → 现在 1556 行。67 → 68 tests |
---
## OpenCodeFloatingPanel 设计要点
### DOM 结构
```
<div class="opencode-floating-root"> (position: fixed; bottom: 24; right: 24)
<button class="opencode-floating-button">🪄</button>
<div class="opencode-floating-panel" hidden>
<!-- OpenCodeInlinePrompt 在这里 -->
</div>
</div>
```
### 生命周期
- 构造:创建 button + panel 容器,挂到 `document.body`,监听 `app.shell.currentChanged`
- toggle():if hidden → _showPrompt;else → _hidePrompt
- `_showPrompt()`:resolve 当前 notebook path + active cell lambda → 构造 `OpenCodeInlinePrompt` → 用 **`Widget.attach(prompt, this._panelEl)`** 挂上 → 调 `prompt.initialize()` 拉 session list → 显示
- `_hidePrompt()`:close SSE → dispose prompt → **defensive DOM cleanup**(见下)→ 设 `this._prompt = null` → 隐藏 panel
### State 全在 panel 上(原来在 cell action 上的都搬过来了)
- `_app: JupyterFrontEnd`
- `_serverSettings: ServerConnection.ISettings | null`(构造器传入)
- `_providers: OpenCodeProvidersResponse | null`(构造器传入,通过 `setProviders()` 方法更新)
- `_prompt: OpenCodeInlinePrompt | null`
- `_sseSub: OpenCodeEventSubscription | null`
- `_notebookPath: string | null`
- `_status: 'idle' | 'loading'`
### API(对外)
```ts
class OpenCodeFloatingPanel extends Widget {
constructor(options: {
app: JupyterFrontEnd;
serverSettings: ServerConnection.ISettings;
providers: OpenCodeProvidersResponse | null;
});
setProviders(p: OpenCodeProvidersResponse | null): void; // index.ts 在 providers fetch 完后调
toggle(): void;
}
```
---
## ⚠️ 刚修的 bug(d412130)
**症状**:点击悬浮按钮 → 显示 panel。点第二次 → 隐藏。点第三次 → **看到 2 个 prompt 同时显示**(节点累积)。
**根因**:`OpenCodeInlinePrompt` 是 Lumino Widget,但我们用 `Widget.attach(prompt, this._panelEl)` 把 prompt 挂到一个裸的 `HTMLDivElement` 上(host 不是 Widget)。Lumino 的 `Widget.dispose()` 检查 `this.parent` 然后 `this.isAttached`:
- `this.parent === null`(HTMLElement host 不会建立 Lumino parent)
- `this.isAttached === true`(节点确实在 DOM 里)
-`Widget.detach(this)` 分支 — **理论上能清理**
但在某些场景下(可能是 message loop 时序 / widget tree 重排),dispose 不会移除 DOM 节点,残留累积。
**修法**(`_hidePrompt` 里加 defensive cleanup):
```ts
if (this._prompt) {
const node = this._prompt.node;
this._prompt.dispose();
// Defensive: ensure DOM cleanup even if dispose() missed it
if (node.parentNode === this._panelEl) {
this._panelEl.removeChild(node);
}
this._prompt = null;
}
```
**Regression test**(`opencode_floating_panel.spec.ts`):
```ts
it('hide+show cycles do not accumulate prompt nodes in the panel (regression)', () => {
for (let i = 0; i < 5; i++) { panel.toggle(); panel.toggle(); }
panel.toggle();
expect(panelEl.querySelectorAll('.opencode-inline-prompt').length).toBe(1);
});
```
⚠️ **测试局限**:测试 mock 的 `Widget.dispose()` 直接用 `node.parentNode.removeChild`,比真实 Lumino 更激进清理。**这个 regression test 在 mock 下永远会过**,对真实 bug 的保护力有限 — 真实 Lumino 才是 bug 触发环境。如果你怀疑类似 bug 复发,**必须在真实 JupyterLab 里手测**,不要只看 jest pass。
---
## 关键设计决策(避免来回改)
1. **不用并存模式** — per-cell 按钮完全删除。Linus "good taste" 原则,消除 special case。
2. **Active cell 通过 shell 拿**`app.shell.currentWidget.content.activeCell`,不是 per-cell 硬绑定。Cell 是 `CodeCell | null`,null 时 `getActiveCell()` 返回 null,prompt 用 `Notification.info('请先选中一个 cell')` 提示。
3. **`_onShellChanged` 是箭头函数 class field**(`= (): void => { ... }`)— Lumino signal 调用时 `this` 不一定正确绑定,arrow function 把 `this` 锁死在实例上。
4. **Defensive DOM cleanup** — 见上面 bug 段。任何把 Lumino widget 挂到非 Widget host 上的代码都应该有这层保险。
5. **Mock 的 `Widget.dispose()` 比真实 Lumino 更"宽容"** — 见 bug 段的测试局限说明。
---
## 仍未做的事(如果继续)
- [ ] **真实 JupyterLab + OpenCode Serve 端到端测试** — 我只做了 build/jest/pytest,没在活的 JupyterLab 里点过。bug d412130 是用户手测报的。
- [ ] **Playwright UI tests** — 项目交接文档里说过写过 spec 但没跑,我没动这块。
- [ ] **`jlpm build:prod`** — 尝试运行但 venv 里 jlpm 入口有问题(`.venv/bin/jlpm` 是 Python 脚本,`main()` 调用对不上),用户没让我再继续 debug 这块。我跑了 `jest` + `tsc --noEmit` 间接验证了编译能过,但完整 labextension 打包没跑过。
- [ ] **pre-existing lint 错的处理**`FlatProvider` / `BlockEntry` 接口名不符合项目 `/^I[A-Z]/u` 规则。这两个是我接手前就存在的。可以单独提一个 cleanup commit。
- [ ] **`tsconfig.json` 没动** — strict mode 让我新加了几处 `as any`(`this._app.shell as any`),是因为 JupyterLab 类型里 `JupyterFrontEnd.shell` 在某些版本是 `JupyterShell | null`。可以后续更精确处理。
---
## 环境 / 工具注意事项(下一个 agent 必读)
### Codex 走得很坎坷 ⚠️⚠️⚠️
CLAUDE.md 强制要求 Codex `model: kimi/kimi-k2.7-code`,但本会话中这个模型在火山方舟 Coding Plan 上**反复返回 HTTP 400**:
| 错误 | 频率 |
|------|------|
| `model: kimi-k2.7-code is not valid`(早期,模型名错) | 2 次 |
| `reasoning is not supported by current model`(用户修了 proxy 后) | 多次 |
| 一次 noop 通了,然后实际任务失败 | 1 次 |
CLAUDE.md 现在的版本是 `kimi/kimi-k2.7-code`(带 `kimi/` 前缀),但**仍然被 proxy 拒绝**。**建议**:
1. 下次会话先 `noop` 测一下,通了再发真任务
2. 不通就走 CC fallback(按 CLAUDE.md fallback 规则:`codex-fail-3x → switch-to-cc`)
3. 真要走 Codex,可能需要 proxy 侧明确配置(我无法触及)
我大部分代码工作都是 CC 手动做的。Codex 临死前**做了一件有用的事**:它已经重写了 spec 文件的 65%(包括 rename + mock 更新 + `getActiveCell` 适配 + `makeFakeApp` helper),省了我不少力气。
### jlpm 在 venv 里不能直接用
`.venv/bin/jlpm` 是 Python 脚本入口(`from jupyter_builder.jlpm import main`)。用户终端环境可能直接有 `jlpm`,但我的环境没有。`python -m jupyter_builder.jlpm` 也没成功。
如果下一个 agent 需要跑 `jlpm build:prod``jlpm watch`,建议先确认 `which jlpm` 能找到。
### 分支保护
`feature/floating-ai-button` 没合并到 `main`,还在 review 阶段。`main` 仍是 `310d507`。如果新会话要在 `main` 上继续,先确认这个分支的 PR 是否已合。
---
## 测试命令(确认改动没破坏)
```bash
.venv/bin/pytest opencode_bridge/tests/ -q # 73 passed
./node_modules/.bin/jest --no-coverage # 68 passed (3 suites)
./node_modules/.bin/tsc --noEmit # exit 0
./node_modules/.bin/eslint --ext .ts,.tsx src/ # 仅 pre-existing 错
```
---
## 上下文管理提示
我已经快把 context 烧完了(写这个交接文档前已经是大半)。如果新会话第一件事是"继续上次工作":
1. 读这份 HANDOVER.md 全文
2.`git log feature/floating-ai-button` 确认 commit hash 没变(`d412130`)
3. 跑上面 4 个验证命令,确认环境干净
4. 然后读这次提交改动的 6 个文件,优先 `src/components/opencode_floating_panel.ts``src/__tests__/opencode_floating_panel.spec.ts`
**不要**:`git checkout main` 然后从 main 重新开始 — 这会丢失当前未合并的分支历史。直接在 `feature/floating-ai-button` 上继续即可。