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

11 KiB

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(对外)

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):

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):

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:prodjlpm watch,建议先确认 which jlpm 能找到。

分支保护

feature/floating-ai-button 没合并到 main,还在 review 阶段。main 仍是 310d507。如果新会话要在 main 上继续,先确认这个分支的 PR 是否已合。


测试命令(确认改动没破坏)

.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.tssrc/__tests__/opencode_floating_panel.spec.ts

不要:git checkout main 然后从 main 重新开始 — 这会丢失当前未合并的分支历史。直接在 feature/floating-ai-button 上继续即可。