Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12ec84a7c8 | ||
|
|
d412130612 | ||
|
|
c0eba7e0e6 |
+148
-105
@@ -2,163 +2,206 @@
|
||||
|
||||
## 状态速览
|
||||
|
||||
**分支**:`main`(已切回,在 main 上操作)
|
||||
**分支**:`feature/floating-ai-button`(已 push,tracking 已设)
|
||||
**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
|
||||
**当前 HEAD (main)**:`d650736 chore: bump version to 0.1.2`
|
||||
**Tag**:`v0.1.2` → `d650736`(已推到远端,CI 会自动 build + 发 Gitea release + Bark 通知)
|
||||
|
||||
**基础**:`main` @ `310d507`(上次 HANDOVER 时的版本)
|
||||
**当前 HEAD**:`d412130`
|
||||
**提交**:
|
||||
```
|
||||
d650736 chore: bump version to 0.1.2 ← main HEAD, v0.1.2
|
||||
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
|
||||
```
|
||||
|
||||
⚠️ **重要分歧**:`main` 这次的 v0.1.2 发布**只包含了 version bump**,没有功能改动。完整功能(per-cell → 全局 floating panel 重构)在分支 `feature/floating-ai-button` 上,**未合并**。git status 是干净的,如果你想继续 feature,先 `git checkout feature/floating-ai-button`。
|
||||
**验证**:✅ Jest 68/68 · ✅ pytest 73/73 · ✅ tsc exit 0 · ⚠️ 2 个 pre-existing lint 错(`FlatProvider`/`BlockEntry` 命名约定,在 `inline_prompt.ts` 里,我没引入)
|
||||
|
||||
**PR**:未提。Gitea 链接:
|
||||
```
|
||||
feature/floating-ai-button HEAD:
|
||||
12ec84a docs: refresh HANDOVER for floating panel work (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
|
||||
(已 push 到远端 + tracking 已设)
|
||||
http://101.43.40.124:3000/tao.chen/notebook-ai-extension/pulls/new/feature/floating-ai-button
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 本次会话做了什么(按时间序)
|
||||
## 本次会话做了什么
|
||||
|
||||
1. **HANDOVER.md 初版**(上一任留下的)— 已读
|
||||
2. **创建 `feature/floating-ai-button` 分支**
|
||||
3. **重构**:把 per-cell 工具栏 AI 按钮(`OpenCodeCellActions`)整个换成全局右下角悬浮按钮 + 弹出 panel
|
||||
- 新增 `src/components/opencode_floating_panel.ts`(Widget,挂 `document.body`)
|
||||
- 删除 `src/components/opencode_cell_actions.ts`(逻辑搬上去)
|
||||
- 改 `src/components/opencode_inline_prompt.ts` — 加 `getActiveCell?: () => CodeCell | null` 回调,3 处 cell 引用全用回调
|
||||
- 改 `src/index.ts` — 删 `toolbarRegistry.addFactory`,接入 floating panel
|
||||
- 改 `style/base.css` — 删 `.opencode-cell-actions`,新增 `.opencode-floating-*`(fixed 定位、z-index 1000)
|
||||
- 重命名 + 重写 `src/__tests__/opencode_floating_panel.spec.ts`
|
||||
4. **Bug fix (d412130)**:hide+show 循环累积 prompt 节点 — 用户手测报告,在 `_hidePrompt` 加 defensive DOM cleanup
|
||||
5. **Commit + push** 到 `feature/floating-ai-button`(只到这,没合并)
|
||||
6. **回 main**:**用户决定在 main 上单独发 v0.1.2**(只 bump 版本号,不含功能)
|
||||
7. **v0.1.2 tag + push**
|
||||
**架构变更**:把 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证状态
|
||||
## 关键改动文件
|
||||
|
||||
| 检查 | 结果 |
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| Jest | ✅ 68/68(3 suites)— **在 feature 分支验证过**,main 上 0.1.2 没跑过 |
|
||||
| pytest | ✅ 73/73(同上,feature 分支) |
|
||||
| `tsc --noEmit` | ✅ exit 0(feature 分支) |
|
||||
| ESLint | ⚠️ 2 pre-existing 命名错(`FlatProvider`/`BlockEntry` 接口名,不符合 `/^I[A-Z]/u`),我没引入 |
|
||||
| `jlpm build:prod` | ⚠️ 未跑(见下) |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 下个 agent 必须知道的几件事
|
||||
## OpenCodeFloatingPanel 设计要点
|
||||
|
||||
### 1. Codex 在本会话里基本不可用 ⚠️⚠️⚠️
|
||||
### 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>
|
||||
```
|
||||
|
||||
CLAUDE.md 强制 `model: kimi/kimi-k2.7-code`(或后来的 `kimi/kimi-k2.6`),但火山方舟 Coding Plan proxy 反复拒绝:
|
||||
### 生命周期
|
||||
- 构造:创建 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
|
||||
|
||||
- `kimi-k2.7-code` — HTTP 400 "model not valid"(模型名错)
|
||||
- `kimi/kimi-k2.7-code`(CLAUDE.md 当前版本) — 同样被拒
|
||||
- `kimi-k2.6` — HTTP 400 "reasoning is not supported"
|
||||
- `kimi/kimi-k2.7-code` noop 偶尔通一次,然后实际任务又失败
|
||||
### 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'`
|
||||
|
||||
**实际代码工作几乎全是 CC 手动做的**。Codex 临死前对 spec 文件做了一次有用的部分重写(改完了 65% 然后挂了)。
|
||||
### 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;
|
||||
}
|
||||
```
|
||||
|
||||
**建议下次**:
|
||||
- 先 noop 测一次 Codex,通了再用;不通直接走 CC fallback(CLAUDE.md 规则 `codex-fail-3x → switch-to-cc`)
|
||||
- 如果必须用 Codex,可能需要你这边再调一次 proxy 配置
|
||||
---
|
||||
|
||||
### 2. d412130 bug 的防御性修复 + 测试局限
|
||||
## ⚠️ 刚修的 bug(d412130)
|
||||
|
||||
**症状**:点悬浮按钮 → 显示 panel。再点 → 隐藏。再点 → **看到 2 个 prompt 同时显示**(节点累积)。
|
||||
**症状**:点击悬浮按钮 → 显示 panel。点第二次 → 隐藏。点第三次 → **看到 2 个 prompt 同时显示**(节点累积)。
|
||||
|
||||
**根因**:`OpenCodeFloatingPanel` 用 `Widget.attach(prompt, this._panelEl)` 把 Lumino Widget 挂到裸 `HTMLDivElement` 上(host 不是 Widget),所以 prompt 的 Lumino `_parent` 是 null。真实 Lumino `Widget.dispose()` 在 parent=null 时走 `Widget.detach(this)` 分支按理能清 DOM,但实际有遗漏场景。
|
||||
**根因**:`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)` 分支 — **理论上能清理**
|
||||
|
||||
**修法**(`src/components/opencode_floating_panel.ts` 的 `_hidePrompt`):
|
||||
但在某些场景下(可能是 message loop 时序 / widget tree 重排),dispose 不会移除 DOM 节点,残留累积。
|
||||
|
||||
**修法**(`_hidePrompt` 里加 defensive cleanup):
|
||||
```ts
|
||||
if (this._prompt) {
|
||||
const node = this._prompt.node;
|
||||
this._prompt.dispose();
|
||||
if (node.parentNode === this._panelEl) { // defensive
|
||||
// Defensive: ensure DOM cleanup even if dispose() missed it
|
||||
if (node.parentNode === this._panelEl) {
|
||||
this._panelEl.removeChild(node);
|
||||
}
|
||||
this._prompt = null;
|
||||
}
|
||||
```
|
||||
|
||||
**回归测试局限**:`src/__tests__/opencode_floating_panel.spec.ts` 的测试 mock `Widget.dispose()` 直接用 `node.parentNode.removeChild` 清 — **比真实 Lumino 更激进**,所以这个回归测试在 mock 下永远过。**真实 bug 只能在真实 JupyterLab 里复现和验证**。如果你看到类似累积 bug,**必须真跑 JupyterLab**,别只信 jest pass。
|
||||
|
||||
### 3. 几个 `_onShellChanged` / `as any` 的取舍
|
||||
|
||||
- `_onShellChanged` 写成 **arrow function class field**(`= (): void => { ... }`)而不是 method,这样 `this` 锁定到实例上(Lumino signal 调用 slot 时 `this` 不可靠)
|
||||
- `this._app.shell as any` 出现过几次 — 因为 `JupyterFrontEnd.shell` 在不同 JupyterLab 版本里类型签名是 `JupyterShell | null`,严格模式要求 cast。可以后续更精确处理,但当前能用
|
||||
|
||||
### 4. `jlpm build:prod` 没跑成功
|
||||
|
||||
`.venv/bin/jlpm` 是 Python 入口脚本,直接 `jlpm build:prod` 找不到。我试过 `python -m jupyter_builder.jlpm build:prod` 但也失败(参数解析对不上)。**完整 labextension 打包未验证** — 只跑了 `jest` + `tsc --noEmit` 间接确认编译能过。如果发版前要打包验证,你可能要在终端手动跑。
|
||||
|
||||
### 5. `v0.1.2` 是个"假版本"
|
||||
|
||||
只 bump 了 `package.json` 的 version 字段(`pyproject.toml` 是 hatch dynamic,从 git tag 自动读),**没有功能变更**。发这个 tag 大概是为了占位 / 触发 CI。下次真发版时需要把 `feature/floating-ai-button` 合并到 main,然后重新打 tag。
|
||||
|
||||
---
|
||||
|
||||
## 仍然没做的事
|
||||
|
||||
- [ ] 把 `feature/floating-ai-button` 合到 main(用户说"暂时不管",所以还没合)
|
||||
- [ ] **真实 JupyterLab + OpenCode Serve 端到端测试** — 没在活的 JupyterLab 里点过按钮。bug d412130 是用户手测报的
|
||||
- [ ] **Playwright UI tests** — 项目交接文档里说写过 spec 但没跑,本次也没动
|
||||
- [ ] **pre-existing lint 错清理**:`FlatProvider` → `IFlatProvider`,`BlockEntry` → `IBlockEntry`(2 处改名,小 PR)
|
||||
- [ ] **`jlpm build:prod`** — 没跑通(见上)
|
||||
- [ ] **CI 验证**:`v0.1.2` tag push 触发 CI,看一下 `.github/workflows/build.yml` 跑通没
|
||||
|
||||
---
|
||||
|
||||
## 关键文件速查(给下任的 quick map)
|
||||
|
||||
```
|
||||
src/components/opencode_floating_panel.ts ← 新文件,核心逻辑都在这(310+ 行)
|
||||
src/components/opencode_inline_prompt.ts ← 解耦 Cell,加了 getActiveCell 选项
|
||||
src/__tests__/opencode_floating_panel.spec.ts ← 重写后的测试(1556 行,68 tests)
|
||||
src/index.ts ← 极简,只剩 activate() + 挂 floating panel
|
||||
style/base.css ← 找 .opencode-floating-* 块
|
||||
**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);
|
||||
});
|
||||
```
|
||||
|
||||
参考架构/HTTP 细节(未变)参考本仓库 HANDOVER.md 之前章节的 [架构](#架构) / [主要路由](#主要路由) / [关键设计决策](#关键设计决策-避免来回改)。
|
||||
⚠️ **测试局限**:测试 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
|
||||
./node_modules/.bin/tsc --noEmit # exit 0
|
||||
./node_modules/.bin/eslint --ext .ts,.tsx src/ # 仅 pre-existing 错
|
||||
|
||||
# 在哪个分支跑? feature/floating-ai-button 上跑过了,main 上 v0.1.2 没跑
|
||||
.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 错
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 上下文管理提示
|
||||
|
||||
上下文快满了。新 agent 第一件事:
|
||||
我已经快把 context 烧完了(写这个交接文档前已经是大半)。如果新会话第一件事是"继续上次工作":
|
||||
|
||||
1. **读这份 HANDOVER.md 全文**(包括本节之前的所有内容)
|
||||
2. `git log --oneline -5` 确认 commit hash 没变
|
||||
3. 跑上面 4 个验证命令
|
||||
4. 如果要继续 feature 工作 → `git checkout feature/floating-ai-button`
|
||||
5. 如果只是在 main 上发版 → 留在这里,准备好就 `git merge feature/floating-ai-button`(或 PR 流程)
|
||||
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`
|
||||
|
||||
**不要**:
|
||||
- 不要从 main 重新开发 — 浪费时间
|
||||
- 不要相信 jest 单测覆盖 d412130 那类 bug — 必须在真 JupyterLab 里点
|
||||
- 不要相信 Codex 能用 — 先 noop 测一下
|
||||
**不要**:`git checkout main` 然后从 main 重新开始 — 这会丢失当前未合并的分支历史。直接在 `feature/floating-ai-button` 上继续即可。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode_bridge",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.1",
|
||||
"description": "A JupyterLab extension.",
|
||||
"keywords": [
|
||||
"jupyter",
|
||||
|
||||
+518
-634
File diff suppressed because it is too large
Load Diff
@@ -1,378 +0,0 @@
|
||||
/**
|
||||
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
||||
* (top-right of the active cell). Clicking it toggles an
|
||||
* OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||
*
|
||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||
*
|
||||
* v4 message flow (matches demo.html):
|
||||
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
|
||||
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
|
||||
* sessionId
|
||||
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
|
||||
* forward each event to the prompt's applyEvent() dispatcher
|
||||
* 4. On session.idle we close the stream and re-enable the input
|
||||
*
|
||||
* The frontend does NO semantic processing of the LLM reply. The chosen
|
||||
* provider/model comes from the inline picker (no settings-based default).
|
||||
*/
|
||||
import type { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import {
|
||||
callOpenCodeBindExistingSession,
|
||||
callOpenCodeCreateNotebookSession,
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeListAllSessions,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
callOpenCodeSessionMessages,
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
|
||||
// Module-level runtime injection (set by index.ts after activation).
|
||||
let _serverSettings: ServerConnection.ISettings | null = null;
|
||||
let _providers: OpenCodeProvidersResponse | null = null;
|
||||
|
||||
export function setOpenCodeServerSettings(
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): void {
|
||||
_serverSettings = serverSettings;
|
||||
}
|
||||
|
||||
export function setOpenCodeProviders(
|
||||
p: OpenCodeProvidersResponse | null
|
||||
): void {
|
||||
_providers = p;
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'loading';
|
||||
|
||||
export class OpenCodeCellActions extends Widget {
|
||||
private _cell: CodeCell;
|
||||
// The cell's notebookPath is the only context we need for the
|
||||
// /edit request. The cell source, error traceback, and previous
|
||||
// cell are no longer auto-injected — the user attaches whatever
|
||||
// they want via the "📋 插入单元格内容" button.
|
||||
private _notebookPath: string | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||
|
||||
constructor(cell: CodeCell) {
|
||||
super();
|
||||
this._cell = cell;
|
||||
this.addClass('opencode-cell-actions');
|
||||
this._cell.model.contentChanged.connect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.connect(this._onModelChange, this);
|
||||
this._render();
|
||||
this._onModelChange();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._closeSse();
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
this._hidePrompt();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private _onModelChange(): void {
|
||||
this._notebookPath = extractNotebookPathFromCell(this._cell);
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
const node = this.node;
|
||||
node.textContent = '';
|
||||
|
||||
const baseDisabled = !this._notebookPath || this._status === 'loading';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'opencode-btn opencode-btn-ai';
|
||||
btn.textContent = 'AI';
|
||||
btn.disabled = baseDisabled;
|
||||
btn.title = baseDisabled
|
||||
? 'OpenCode: 等待 cell 上下文…'
|
||||
: '让 AI 修改这个 cell 的代码';
|
||||
btn.addEventListener('click', () => {
|
||||
this._togglePrompt();
|
||||
});
|
||||
node.appendChild(btn);
|
||||
}
|
||||
|
||||
private _togglePrompt(): void {
|
||||
if (this._prompt) {
|
||||
this._hidePrompt();
|
||||
} else {
|
||||
this._showPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
private _showPrompt(): void {
|
||||
if (this._prompt) {
|
||||
return;
|
||||
}
|
||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||
disabled: !this._notebookPath || this._status === 'loading',
|
||||
providers: _providers,
|
||||
notebookPath: this._notebookPath ?? undefined,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
onCancel: () => {
|
||||
this._hidePrompt();
|
||||
},
|
||||
onPermissionReply: async (permId, response) => {
|
||||
if (!_serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyPermission(
|
||||
sessionId,
|
||||
permId,
|
||||
response,
|
||||
_serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: permission reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onQuestionReply: async (qId, answer) => {
|
||||
if (!_serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyQuestion(
|
||||
sessionId,
|
||||
qId,
|
||||
answer,
|
||||
_serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: question reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onStreamEnd: () => {
|
||||
// Centralized: the prompt detected "idle" (any shape) and tells
|
||||
// us to close the SSE stream + restore the input. We no longer
|
||||
// parse event types here.
|
||||
this._closeSse();
|
||||
},
|
||||
onListSessions: async () => {
|
||||
if (!_serverSettings) return [];
|
||||
try {
|
||||
const r = await callOpenCodeListAllSessions(_serverSettings);
|
||||
return r.sessions || [];
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: list all sessions failed', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
onCreateSession: async () => {
|
||||
if (!_serverSettings || !this._notebookPath) {
|
||||
throw new Error('notebook not ready');
|
||||
}
|
||||
// Tear down any in-flight SSE first — the old session is
|
||||
// being replaced.
|
||||
this._closeSse();
|
||||
const r = await callOpenCodeCreateNotebookSession(
|
||||
this._notebookPath,
|
||||
undefined,
|
||||
_serverSettings
|
||||
);
|
||||
if (!r.ok || !r.sessionId) {
|
||||
throw new Error(r.error || 'create_session failed');
|
||||
}
|
||||
// The prompt's _handleCreateSession calls setSessionId
|
||||
// internally; no need to set it here too.
|
||||
return r.sessionId;
|
||||
},
|
||||
onSwitchSession: async (sessionId: string) => {
|
||||
if (!_serverSettings || !this._notebookPath) {
|
||||
return false;
|
||||
}
|
||||
// Closing the SSE first ensures the old session's events stop
|
||||
// coming in while we transition.
|
||||
this._closeSse();
|
||||
// PUT /sessions/notebook binds AND sets active in one call.
|
||||
// (The separate PUT /sessions/active is only for switching
|
||||
// between sessions that are ALREADY bound; for a session the
|
||||
// user just picked from the global list, we need to bind
|
||||
// first — calling set_active before bind returns 400.)
|
||||
const r = await callOpenCodeBindExistingSession(
|
||||
this._notebookPath,
|
||||
sessionId,
|
||||
_serverSettings
|
||||
);
|
||||
if (!r.ok) {
|
||||
return false;
|
||||
}
|
||||
// The prompt's _handleSwitchSession calls setSessionId
|
||||
// internally; no need to set it here too.
|
||||
return true;
|
||||
},
|
||||
onReloadHistory: async () => {
|
||||
if (!this._notebookPath) return;
|
||||
await this._refreshHistory(this._notebookPath);
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Fetch the current session's static history and render it before
|
||||
// streaming begins. Empty list is a normal no-op.
|
||||
const notebookPath = this._notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
|
||||
// Populate the session selector dropdown (loads OpenCode's global
|
||||
// session list async; if the host didn't wire up the callbacks,
|
||||
// the row is simply not rendered).
|
||||
prompt.initialize();
|
||||
}
|
||||
|
||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||
if (!_serverSettings || !this._prompt) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await callOpenCodeSessionMessages(
|
||||
notebookPath,
|
||||
_serverSettings
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
|
||||
private _hidePrompt(): void {
|
||||
if (this._prompt) {
|
||||
this._prompt.dispose();
|
||||
this._prompt = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async _onSubmit(
|
||||
text: string,
|
||||
providerId?: string,
|
||||
modelId?: string
|
||||
): Promise<void> {
|
||||
if (!this._notebookPath) {
|
||||
return;
|
||||
}
|
||||
if (!_serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
prompt: text,
|
||||
context: { notebookPath: this._notebookPath },
|
||||
providerId,
|
||||
modelId
|
||||
};
|
||||
|
||||
this._status = 'loading';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(true);
|
||||
this._prompt.setSessionId(null);
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
} catch (e) {
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Async edit accepted. Bind the prompt to the returned session, clear
|
||||
// the input, then subscribe to the SSE event stream for this session.
|
||||
this._prompt?.setSessionId(resp.sessionId);
|
||||
this._prompt?.clearInput();
|
||||
this._sseSub = subscribeOpenCodeEvents(
|
||||
resp.sessionId,
|
||||
_serverSettings,
|
||||
{
|
||||
onEvent: ev => {
|
||||
this._prompt?.applyEvent(ev);
|
||||
// Stream-end is detected by the prompt and signalled via
|
||||
// onStreamEnd (see _showPrompt); no idle parsing here.
|
||||
},
|
||||
onError: err => {
|
||||
console.warn('opencode_bridge: SSE error', err);
|
||||
// Don't flip status here: the server may just have hiccuped.
|
||||
// session.idle (if it comes through) will re-enable the input.
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _closeSse(): void {
|
||||
if (this._sseSub) {
|
||||
this._sseSub.close();
|
||||
this._sseSub = null;
|
||||
}
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the parent chain to find the cell's parent NotebookPanel and
|
||||
* return its content path. Returns null if the cell is detached
|
||||
* (no notebook parent).
|
||||
*/
|
||||
function extractNotebookPathFromCell(cell: CodeCell): string | null {
|
||||
let node: Widget | null = cell.parent;
|
||||
while (node) {
|
||||
const candidate = node as any;
|
||||
if (
|
||||
candidate.context &&
|
||||
typeof candidate.context.path === 'string' &&
|
||||
candidate.content &&
|
||||
Array.isArray(candidate.content.widgets)
|
||||
) {
|
||||
return candidate.context.path;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Global floating AI button + panel, mounted on document.body. Replaces
|
||||
* the per-cell toolbar AI button (formerly OpenCodeCellActions). Click
|
||||
* the round bottom-right button to expand the OpenCodeInlinePrompt; click
|
||||
* again to collapse it.
|
||||
*
|
||||
* The panel operates on the JupyterLab shell's currently-active
|
||||
* NotebookPanel — the (notebookPath, activeCell) pair is resolved at
|
||||
* panel-open time from `app.shell.currentWidget`, and re-resolved when
|
||||
* `app.shell.currentChanged` fires while the panel is open.
|
||||
*
|
||||
* All session/SSE/history state that used to live on
|
||||
* OpenCodeCellActions now lives here. The prompt is created lazily on
|
||||
* the first panel open and disposed on close (recreated on every
|
||||
* re-open so the user always sees a fresh history fetch).
|
||||
*/
|
||||
import { JupyterFrontEnd } from '@jupyterlab/application';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import type { CodeCell } from '@jupyterlab/cells';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import {
|
||||
callOpenCodeBindExistingSession,
|
||||
callOpenCodeCreateNotebookSession,
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeListAllSessions,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
callOpenCodeSessionMessages,
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
|
||||
export interface IOpenCodeFloatingPanelOptions {
|
||||
app: JupyterFrontEnd;
|
||||
serverSettings: ServerConnection.ISettings;
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'loading';
|
||||
|
||||
export class OpenCodeFloatingPanel extends Widget {
|
||||
private _app: JupyterFrontEnd;
|
||||
private _serverSettings: ServerConnection.ISettings | null;
|
||||
private _providers: OpenCodeProvidersResponse | null;
|
||||
private _button: HTMLButtonElement;
|
||||
private _panelEl: HTMLDivElement;
|
||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||
private _notebookPath: string | null = null;
|
||||
private _status: Status = 'idle';
|
||||
|
||||
constructor(options: IOpenCodeFloatingPanelOptions) {
|
||||
super();
|
||||
this._app = options.app;
|
||||
this._serverSettings = options.serverSettings;
|
||||
this._providers = options.providers;
|
||||
this.addClass('opencode-floating-root');
|
||||
this.node.style.position = 'fixed';
|
||||
this.node.style.bottom = '24px';
|
||||
this.node.style.right = '24px';
|
||||
this.node.style.zIndex = '1000';
|
||||
|
||||
this._button = document.createElement('button');
|
||||
this._button.className = 'opencode-floating-button';
|
||||
this._button.textContent = '🪄';
|
||||
this._button.title = 'OpenCode AI';
|
||||
this._button.addEventListener('click', () => this.toggle());
|
||||
this.node.appendChild(this._button);
|
||||
|
||||
this._panelEl = document.createElement('div');
|
||||
this._panelEl.className = 'opencode-floating-panel';
|
||||
this._panelEl.hidden = true;
|
||||
this.node.appendChild(this._panelEl);
|
||||
|
||||
// JupyterFrontEnd.shell is typed as `JupyterShell | null` in some
|
||||
// JupyterLab versions; cast through any to satisfy strict null checks
|
||||
// while keeping the public API simple.
|
||||
(this._app.shell as any).currentChanged.connect(this._onShellChanged, this);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
(this._app.shell as any).currentChanged.disconnect(
|
||||
this._onShellChanged,
|
||||
this
|
||||
);
|
||||
this._closeSse();
|
||||
this._hidePrompt();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/** Update the providers payload after the initial async fetch. */
|
||||
setProviders(p: OpenCodeProvidersResponse | null): void {
|
||||
this._providers = p;
|
||||
}
|
||||
|
||||
/** Toggle the panel open/closed. */
|
||||
toggle(): void {
|
||||
if (this._panelEl.hidden) {
|
||||
this._showPrompt();
|
||||
} else {
|
||||
this._hidePrompt();
|
||||
}
|
||||
}
|
||||
|
||||
private _onShellChanged = (): void => {
|
||||
// The active notebook changed. If the panel is open, refresh the
|
||||
// underlying prompt so the (provider, model) selection, history,
|
||||
// and active-cell context all reflect the new notebook.
|
||||
//
|
||||
// Implemented as an arrow-function class field (not a method) so
|
||||
// `this` is bound regardless of how the signal's slot invokes us.
|
||||
if (!this._panelEl.hidden) {
|
||||
this._hidePrompt();
|
||||
this._showPrompt();
|
||||
}
|
||||
};
|
||||
|
||||
private _resolveNotebookContext(): {
|
||||
notebookPath: string | null;
|
||||
getActiveCell: () => CodeCell | null;
|
||||
} {
|
||||
const widget = this._app.shell.currentWidget as any;
|
||||
const notebookPath =
|
||||
widget && widget.context && typeof widget.context.path === 'string'
|
||||
? (widget.context.path as string)
|
||||
: null;
|
||||
const getActiveCell = (): CodeCell | null => {
|
||||
const cur = this._app.shell.currentWidget as any;
|
||||
if (!cur || !cur.content) {
|
||||
return null;
|
||||
}
|
||||
const active = cur.content.activeCell;
|
||||
return (active as CodeCell) || null;
|
||||
};
|
||||
return { notebookPath, getActiveCell };
|
||||
}
|
||||
|
||||
private _showPrompt(): void {
|
||||
if (this._prompt) {
|
||||
this._panelEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
const { notebookPath, getActiveCell } = this._resolveNotebookContext();
|
||||
this._notebookPath = notebookPath;
|
||||
const prompt = new OpenCodeInlinePrompt({
|
||||
disabled: !this._notebookPath || this._status === 'loading',
|
||||
providers: this._providers,
|
||||
notebookPath: this._notebookPath ?? undefined,
|
||||
getActiveCell,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
onCancel: () => {
|
||||
this._hidePrompt();
|
||||
},
|
||||
onPermissionReply: async (permId, response) => {
|
||||
if (!this._serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyPermission(
|
||||
sessionId,
|
||||
permId,
|
||||
response,
|
||||
this._serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: permission reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onQuestionReply: async (qId, answer) => {
|
||||
if (!this._serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyQuestion(
|
||||
sessionId,
|
||||
qId,
|
||||
answer,
|
||||
this._serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: question reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onStreamEnd: () => {
|
||||
// Centralized: the prompt detected "idle" and signals us to
|
||||
// close the SSE stream + restore the input.
|
||||
this._closeSse();
|
||||
},
|
||||
onListSessions: async () => {
|
||||
if (!this._serverSettings) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeListAllSessions(this._serverSettings);
|
||||
return r.sessions || [];
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: list all sessions failed', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
onCreateSession: async () => {
|
||||
if (!this._serverSettings || !this._notebookPath) {
|
||||
throw new Error('notebook not ready');
|
||||
}
|
||||
this._closeSse();
|
||||
const r = await callOpenCodeCreateNotebookSession(
|
||||
this._notebookPath,
|
||||
undefined,
|
||||
this._serverSettings
|
||||
);
|
||||
if (!r.ok || !r.sessionId) {
|
||||
throw new Error(r.error || 'create_session failed');
|
||||
}
|
||||
return r.sessionId;
|
||||
},
|
||||
onSwitchSession: async (sessionId: string) => {
|
||||
if (!this._serverSettings || !this._notebookPath) {
|
||||
return false;
|
||||
}
|
||||
this._closeSse();
|
||||
// PUT /sessions/notebook binds AND sets active in one call.
|
||||
const r = await callOpenCodeBindExistingSession(
|
||||
this._notebookPath,
|
||||
sessionId,
|
||||
this._serverSettings
|
||||
);
|
||||
return !!r.ok;
|
||||
},
|
||||
onReloadHistory: async () => {
|
||||
if (!this._notebookPath) {
|
||||
return;
|
||||
}
|
||||
await this._refreshHistory(this._notebookPath);
|
||||
}
|
||||
});
|
||||
// Use Widget.attach (not raw appendChild) so Lumino sends the
|
||||
// before-attach / after-attach messages — the prompt relies on
|
||||
// these for isAttached/isVisible tracking. The defensive DOM
|
||||
// cleanup in _hidePrompt handles the case where dispose() leaves
|
||||
// a residual node behind (the panel host is an HTMLElement, not a
|
||||
// Widget, so the Lumino parent stays null and dispose() can't
|
||||
// always clean up the DOM on its own).
|
||||
Widget.attach(prompt, this._panelEl);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Load the current session's static history before streaming starts.
|
||||
if (this._notebookPath) {
|
||||
void this._refreshHistory(this._notebookPath);
|
||||
}
|
||||
prompt.initialize();
|
||||
this._panelEl.hidden = false;
|
||||
}
|
||||
|
||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||
if (!this._serverSettings || !this._prompt) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await callOpenCodeSessionMessages(
|
||||
notebookPath,
|
||||
this._serverSettings
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
|
||||
private _hidePrompt(): void {
|
||||
this._closeSse();
|
||||
if (this._prompt) {
|
||||
const node = this._prompt.node;
|
||||
this._prompt.dispose();
|
||||
// Defensive DOM cleanup: the prompt is attached via raw DOM
|
||||
// (appendChild/Widget.attach with an HTMLElement host), so its
|
||||
// Lumino parent is null. In some scenarios — for example when
|
||||
// the dispose path runs synchronously while another show+hide
|
||||
// cycle is already in flight — the dispose() call does not
|
||||
// remove the node from this._panelEl, leaving a residual node
|
||||
// that the next _showPrompt appends to. Without this defensive
|
||||
// removeChild, the user would see the panel content multiply
|
||||
// with every click.
|
||||
if (node.parentNode === this._panelEl) {
|
||||
this._panelEl.removeChild(node);
|
||||
}
|
||||
this._prompt = null;
|
||||
}
|
||||
this._panelEl.hidden = true;
|
||||
}
|
||||
|
||||
private async _onSubmit(
|
||||
text: string,
|
||||
providerId?: string,
|
||||
modelId?: string
|
||||
): Promise<void> {
|
||||
if (!this._notebookPath) {
|
||||
Notification.info('请先打开一个 notebook');
|
||||
return;
|
||||
}
|
||||
if (!this._serverSettings) {
|
||||
Notification.error('OpenCode 运行时未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const request = {
|
||||
prompt: text,
|
||||
context: { notebookPath: this._notebookPath },
|
||||
providerId,
|
||||
modelId
|
||||
};
|
||||
|
||||
this._status = 'loading';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(true);
|
||||
this._prompt.setSessionId(null);
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await callOpenCodeEdit(request, this._serverSettings);
|
||||
} catch (e) {
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._prompt?.setSessionId(resp.sessionId);
|
||||
this._prompt?.clearInput();
|
||||
this._sseSub = subscribeOpenCodeEvents(
|
||||
resp.sessionId,
|
||||
this._serverSettings,
|
||||
{
|
||||
onEvent: ev => {
|
||||
this._prompt?.applyEvent(ev);
|
||||
},
|
||||
onError: err => {
|
||||
console.warn('opencode_bridge: SSE error', err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _closeSse(): void {
|
||||
if (this._sseSub) {
|
||||
this._sseSub.close();
|
||||
this._sseSub = null;
|
||||
}
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@
|
||||
* history area. The user can still Copy / Insert / Replace individual
|
||||
* code blocks from a finished assistant message.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import type { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { marked } from 'marked';
|
||||
@@ -31,10 +31,7 @@ import type {
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeSessionMeta
|
||||
} from '../types';
|
||||
import {
|
||||
loadModelSelection,
|
||||
saveModelSelection
|
||||
} from './model_selection';
|
||||
import { loadModelSelection, saveModelSelection } from './model_selection';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
@@ -48,6 +45,16 @@ export interface IOpenCodeInlinePromptOptions {
|
||||
* to the default selection (first provider / `default[pid]` model).
|
||||
*/
|
||||
notebookPath?: string;
|
||||
/**
|
||||
* Return the currently active CodeCell, used by the cell-edit actions
|
||||
* ("📋 插入单元格内容" / "插入到光标" / "替换单元格"). When the host is
|
||||
* decoupled from a specific cell (e.g. a global floating panel that
|
||||
* targets whatever cell the user last focused), it passes a lambda
|
||||
* that resolves the active cell from the JupyterLab shell at click
|
||||
* time. Returns null if no cell is currently selectable — the prompt
|
||||
* then surfaces a "请先选中一个 cell" notification instead of acting.
|
||||
*/
|
||||
getActiveCell?: () => CodeCell | null;
|
||||
/**
|
||||
* Respond to a `permission.asked` event. Returns a promise that resolves
|
||||
* to true on success, false on failure (the prompt updates the UI in
|
||||
@@ -81,19 +88,22 @@ export interface IOpenCodeInlinePromptOptions {
|
||||
onReloadHistory?: () => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
interface FlatProvider {
|
||||
id: string;
|
||||
name?: string;
|
||||
models: { [modelID: string]: unknown };
|
||||
}
|
||||
|
||||
function flattenProviders(
|
||||
providers: OpenCodeProvidersResponse | null
|
||||
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
|
||||
function flattenProviders(providers: OpenCodeProvidersResponse | null): {
|
||||
providers: FlatProvider[];
|
||||
defaultMap: { [pid: string]: string };
|
||||
} {
|
||||
const out: FlatProvider[] = [];
|
||||
if (!providers || !providers.providers) {
|
||||
return { providers: out, defaultMap: (providers && providers.default) || {} };
|
||||
return {
|
||||
providers: out,
|
||||
defaultMap: (providers && providers.default) || {}
|
||||
};
|
||||
}
|
||||
for (const p of providers.providers) {
|
||||
if (!p.models || typeof p.models !== 'object') {
|
||||
@@ -118,7 +128,10 @@ function pickDefaultModelId(
|
||||
return undefined;
|
||||
}
|
||||
const fromDefault = defaultMap[providerId];
|
||||
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
|
||||
if (
|
||||
fromDefault &&
|
||||
Object.prototype.hasOwnProperty.call(models, fromDefault)
|
||||
) {
|
||||
return fromDefault;
|
||||
}
|
||||
return keys[0];
|
||||
@@ -130,7 +143,6 @@ interface BlockEntry {
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _cell: CodeCell;
|
||||
private _options: IOpenCodeInlinePromptOptions;
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
@@ -161,12 +173,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
||||
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
||||
|
||||
constructor(
|
||||
cell: CodeCell,
|
||||
options: IOpenCodeInlinePromptOptions
|
||||
) {
|
||||
constructor(options: IOpenCodeInlinePromptOptions) {
|
||||
super();
|
||||
this._cell = cell;
|
||||
this._options = options;
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
@@ -226,7 +234,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
const modelId = this._modelSelect?.value || undefined;
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
this._textarea.addEventListener('keydown', (e) => {
|
||||
this._textarea.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
this._sendBtn.click();
|
||||
@@ -247,7 +255,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this._insertCellBtn.type = 'button';
|
||||
this._insertCellBtn.className = 'opencode-btn-insert-cell';
|
||||
this._insertCellBtn.textContent = '📋 插入单元格内容';
|
||||
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
||||
this._insertCellBtn.title =
|
||||
'把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
|
||||
this._insertCellBtn.addEventListener('click', () => {
|
||||
this._insertCellSource();
|
||||
});
|
||||
@@ -348,6 +357,15 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
|
||||
this.node.appendChild(this._textarea);
|
||||
this.node.appendChild(actions);
|
||||
|
||||
// Apply the initial disabled state to ALL interactive elements
|
||||
// (provider/model selects, send button, AND the insert-cell
|
||||
// button — the last one depends on `getActiveCell()` which only
|
||||
// resolves correctly here in the constructor). Without this, the
|
||||
// insert button would stay enabled until the first external
|
||||
// setDisabled() call, which is too late for the user's first
|
||||
// click.
|
||||
this.setDisabled(options.disabled);
|
||||
}
|
||||
|
||||
private _rebuildModelSelect(
|
||||
@@ -606,6 +624,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
// closes the SSE on switch.
|
||||
this._sessionSelect.disabled = false;
|
||||
}
|
||||
// The "📋 插入单元格内容" button is only meaningful when there is
|
||||
// an active cell. Reflect availability in its disabled state so
|
||||
// the user can see at a glance whether the action will work, and
|
||||
// the click handler can still no-op gracefully via Notification.
|
||||
if (this._insertCellBtn) {
|
||||
const hasActiveCell = !!this._options.getActiveCell?.();
|
||||
this._insertCellBtn.disabled = disabled || !hasActiveCell;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the user input textarea. */
|
||||
@@ -618,13 +644,18 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
* markdown code block to the textarea. The LLM only sees what the
|
||||
* user explicitly chose to include — the cell source is NEVER
|
||||
* auto-injected (v0.2.x change).
|
||||
*
|
||||
* The cell is resolved at click time through the host-provided
|
||||
* `getActiveCell` callback, so the prompt itself stays decoupled
|
||||
* from any specific CodeCell instance.
|
||||
*/
|
||||
private _insertCellSource(): void {
|
||||
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
|
||||
Notification.info('当前 cell 没有内容');
|
||||
const cell = this._options.getActiveCell?.() ?? null;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
Notification.info('请先选中一个 cell');
|
||||
return;
|
||||
}
|
||||
const source = this._cell.model.sharedModel.getSource() as string;
|
||||
const source = cell.model.sharedModel.getSource() as string;
|
||||
if (!source || !source.trim()) {
|
||||
Notification.info('当前 cell 为空');
|
||||
return;
|
||||
@@ -632,10 +663,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
// Use the cell's language as the fence info string when it
|
||||
// looks like a real language identifier; otherwise use a plain
|
||||
// fence so markdown stays portable.
|
||||
const langRaw =
|
||||
(this._cell.model as any).sharedModel && (this._cell.model as any).type
|
||||
? ((this._cell.model as any).type as string)
|
||||
: 'python';
|
||||
const langRaw = (cell.model as any).type
|
||||
? ((cell.model as any).type as string)
|
||||
: 'python';
|
||||
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
|
||||
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
|
||||
const before = this._textarea.value;
|
||||
@@ -710,9 +740,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
applyEvent(event: OpenCodeEvent): void {
|
||||
const type = event.type || (event.payload && event.payload.type) || '';
|
||||
const props =
|
||||
event.properties ||
|
||||
(event.payload && event.payload.properties) ||
|
||||
{};
|
||||
event.properties || (event.payload && event.payload.properties) || {};
|
||||
const sessionID =
|
||||
props.sessionID ||
|
||||
props.sessionId ||
|
||||
@@ -761,13 +789,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
type: string,
|
||||
props: { [k: string]: any }
|
||||
): boolean {
|
||||
|
||||
if (type === 'permission.asked') {
|
||||
const permId = String(props.id || props.permissionID || '');
|
||||
this._createPermissionBlock(
|
||||
permId,
|
||||
String(
|
||||
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
|
||||
props.description ||
|
||||
props.title ||
|
||||
'系统请求执行权限(命令行/文件修改)'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
@@ -819,7 +848,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.input.delta') {
|
||||
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具输入与参数',
|
||||
String(props.delta || '')
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.progress') {
|
||||
@@ -881,10 +914,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
const payload = event.payload;
|
||||
if (payload) {
|
||||
return check(
|
||||
payload.type || topType,
|
||||
payload.properties || topProps
|
||||
);
|
||||
return check(payload.type || topType, payload.properties || topProps);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -970,8 +1000,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '🔐 权限请求等待处理';
|
||||
@@ -1053,8 +1082,7 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.className = 'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '❓ Agent 提问等待回复';
|
||||
@@ -1077,14 +1105,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
submit.className = 'opencode-q-submit';
|
||||
submit.textContent = '提交回答';
|
||||
submit.addEventListener('click', () => {
|
||||
void this._handleQuestionResponse(
|
||||
questionId,
|
||||
input,
|
||||
inputRow,
|
||||
details
|
||||
);
|
||||
void this._handleQuestionResponse(questionId, input, inputRow, details);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit.click();
|
||||
@@ -1112,7 +1135,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
return;
|
||||
}
|
||||
if (!this._options.onQuestionReply) {
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||
inputRow.innerHTML =
|
||||
'<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||
return;
|
||||
}
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
||||
@@ -1242,8 +1266,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
|
||||
private _insertAtCursor(text: string): void {
|
||||
const cell = this._cell;
|
||||
const cell = this._options.getActiveCell?.() ?? null;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
Notification.info('请先选中一个 cell');
|
||||
return;
|
||||
}
|
||||
const sharedModel = cell.model.sharedModel;
|
||||
@@ -1251,9 +1276,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
let offset = source.length;
|
||||
try {
|
||||
const editor = (cell as any).editor;
|
||||
const pos = editor && editor.getCursorPosition
|
||||
? editor.getCursorPosition()
|
||||
: null;
|
||||
const pos =
|
||||
editor && editor.getCursorPosition ? editor.getCursorPosition() : null;
|
||||
if (pos) {
|
||||
const lines = source.split('\n');
|
||||
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
||||
@@ -1276,8 +1300,9 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
|
||||
private _replaceCell(text: string): void {
|
||||
const cell = this._cell;
|
||||
const cell = this._options.getActiveCell?.() ?? null;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
Notification.info('请先选中一个 cell');
|
||||
return;
|
||||
}
|
||||
cell.model.sharedModel.setSource(text);
|
||||
|
||||
+23
-41
@@ -3,74 +3,56 @@ import {
|
||||
JupyterFrontEndPlugin
|
||||
} from '@jupyterlab/application';
|
||||
|
||||
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
|
||||
import { Cell, CodeCell } from '@jupyterlab/cells';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import {
|
||||
OpenCodeCellActions,
|
||||
setOpenCodeProviders,
|
||||
setOpenCodeServerSettings
|
||||
} from './components/opencode_cell_actions';
|
||||
import { OpenCodeFloatingPanel } from './components/opencode_floating_panel';
|
||||
import { requestAPI } from './request';
|
||||
import { callOpenCodeProviders } from './api/opencode_client';
|
||||
|
||||
/**
|
||||
* Initialization data for the opencode_bridge extension.
|
||||
*
|
||||
* v3-final: no JupyterLab plugin settings. Connection config is read from
|
||||
* environment variables by the server extension; the model is picked
|
||||
* dynamically in the inline prompt.
|
||||
* v5-floating: the per-cell AI button is gone. A single
|
||||
* OpenCodeFloatingPanel is attached to document.body and operates
|
||||
* against the JupyterLab shell's currently-active notebook. The model
|
||||
* is picked dynamically in the inline prompt.
|
||||
*/
|
||||
const plugin: JupyterFrontEndPlugin<void> = {
|
||||
id: 'opencode_bridge:plugin',
|
||||
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
|
||||
autoStart: true,
|
||||
optional: [IToolbarWidgetRegistry],
|
||||
activate: (
|
||||
app: JupyterFrontEnd,
|
||||
toolbarRegistry: IToolbarWidgetRegistry | null
|
||||
) => {
|
||||
activate: (app: JupyterFrontEnd) => {
|
||||
console.log('JupyterLab extension opencode_bridge is activated!');
|
||||
|
||||
// Push the Jupyter server settings into the actions module so that
|
||||
// callOpenCodeEdit can build the right base URL.
|
||||
setOpenCodeServerSettings(app.serviceManager.serverSettings);
|
||||
// Mount the floating AI button on the body. The panel itself is
|
||||
// hidden until the user clicks the button.
|
||||
const floating = new OpenCodeFloatingPanel({
|
||||
app,
|
||||
serverSettings: app.serviceManager.serverSettings,
|
||||
providers: null
|
||||
});
|
||||
Widget.attach(floating, document.body);
|
||||
|
||||
// Register the per-cell AI actions into the native Cell toolbar
|
||||
// (top-right of the active cell, next to move up/down). Non-code
|
||||
// cells get an empty widget so they show no AI buttons.
|
||||
if (toolbarRegistry) {
|
||||
toolbarRegistry.addFactory<Cell>(
|
||||
'Cell',
|
||||
'opencode-cell-actions',
|
||||
(cell: Cell) => {
|
||||
if (cell instanceof CodeCell) {
|
||||
return new OpenCodeCellActions(cell);
|
||||
}
|
||||
return new Widget();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch available providers once at activation and cache them for the
|
||||
// inline prompt's model picker. Failure is non-fatal (the picker hides).
|
||||
// Fetch available providers once at activation and cache them for
|
||||
// the inline prompt's model picker. Failure is non-fatal (the
|
||||
// picker hides). Also push the payload into the floating panel so
|
||||
// the prompt can render selects without an extra round trip.
|
||||
void callOpenCodeProviders(app.serviceManager.serverSettings)
|
||||
.then(data => {
|
||||
setOpenCodeProviders(data);
|
||||
floating.setProviders(data);
|
||||
const lines: string[] = [
|
||||
'[opencode_bridge] Available OpenCode providers:'
|
||||
];
|
||||
for (const p of data.providers) {
|
||||
const models = Object.values(p.models).map(m => m.id).join(', ');
|
||||
const models = Object.values(p.models)
|
||||
.map(m => m.id)
|
||||
.join(', ');
|
||||
lines.push(` - ${p.id}: ${models || '(no models)'}`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(lines.join('\n'));
|
||||
})
|
||||
.catch(reason => {
|
||||
// eslint-disable-next-line no-console
|
||||
+ console.warn(
|
||||
console.warn(
|
||||
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
|
||||
reason
|
||||
);
|
||||
|
||||
+57
-15
@@ -4,30 +4,72 @@
|
||||
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
|
||||
*/
|
||||
|
||||
/* Single AI action button inside the native Cell toolbar (active cell, top-right). */
|
||||
.opencode-cell-actions {
|
||||
/* Floating AI launcher (replaces the per-cell toolbar button).
|
||||
Mounted on document.body with position: fixed so it sits above the
|
||||
JupyterLab layout regardless of which dock is active. The panel
|
||||
(when open) renders above the button via column flex. */
|
||||
.opencode-floating-root {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 1000; /* above JupyterLab's main UI (z-index tops out around 100) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.opencode-cell-actions .opencode-btn {
|
||||
.opencode-floating-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 6px;
|
||||
font-size: var(--jp-ui-font-size1);
|
||||
line-height: 20px;
|
||||
background: var(--jp-brand-color1, #1976d2);
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.opencode-floating-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.opencode-floating-button:focus {
|
||||
outline: 2px solid var(--jp-brand-color2, #42a5f5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.opencode-cell-actions .opencode-btn:hover:enabled {
|
||||
background: var(--jp-layout-color2);
|
||||
border-radius: 2px;
|
||||
.opencode-floating-panel {
|
||||
width: 480px;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: 60vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
border: 1px solid var(--jp-border-color2, #ddd);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.opencode-floating-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.opencode-cell-actions .opencode-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
/* The inline prompt inside the floating panel should fill it: drop
|
||||
the standalone panel's own margin/border, and let the panel's
|
||||
scrollable region take over. */
|
||||
.opencode-floating-panel .opencode-inline-prompt {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.opencode-floating-panel .opencode-inline-history {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
/* Inline prompt panel attached to the cell when the AI button is clicked. */
|
||||
|
||||
Reference in New Issue
Block a user