Author SHA1 Message Date
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
tao.chen d412130612 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
Bug: clicking the floating button after the first time caused the panel
content to multiply — every hide+show cycle appended a new prompt while
the previous one stayed in the DOM.

Root cause: the prompt's Lumino parent is null (it's attached to a raw
HTMLDivElement, not a Widget, so neither appendChild nor
Widget.attach(widget, HTMLElement) sets a Lumino parent). The
defensive DOM cleanup in _hidePrompt now removes the prompt's node
explicitly before nulling the reference, so no residual nodes linger
across show+hide cycles. Also use Widget.attach in _showPrompt for the
before-attach / after-attach lifecycle messages the prompt may rely on.

Added a regression test that toggles 5 times and asserts the panel
contains exactly one .opencode-inline-prompt node.

Files:
- src/components/opencode_floating_panel.ts — defensive cleanup + attach
- src/__tests__/opencode_floating_panel.spec.ts — regression test
2026-07-29 09:43:59 +08:00
tao.chen c0eba7e0e6 feat: replace per-cell AI button with global floating panel
The per-cell toolbar AI button (OpenCodeCellActions) is removed. A
single round button is mounted on document.body at the bottom-right of
the JupyterLab shell; clicking it opens a floating panel containing
the same OpenCodeInlinePrompt widget. Clicking again closes it.

The panel resolves the active notebook from app.shell.currentWidget
at open time and re-resolves it whenever currentChanged fires while
the panel is open. The active cell (used by the 📋 插入单元格内容,
↪ Insert, and ⟳ Replace cell actions) is resolved via a new
getActiveCell callback in OpenCodeInlinePrompt, decoupling the prompt
itself from any specific CodeCell instance.

Files:
- src/components/opencode_floating_panel.ts (new) — global panel
- src/components/opencode_cell_actions.ts (deleted) — logic moved up
- src/components/opencode_inline_prompt.ts — getActiveCell option
- src/index.ts — wire floating panel, drop toolbar factory
- style/base.css — .opencode-floating-* styles
- src/__tests__/opencode_floating_panel.spec.ts — new tests (renamed)

All existing functionality preserved: multi-session UI, SSE
streaming, permission/question interaction, history reload.
2026-07-28 19:34:52 +08:00
tao.chenandClaude f9d52f7b8e docs: add HANDOVER.md for next session context
The current session's context window is nearly full. This document
captures the project state, the key design decisions that future
maintainers should NOT re-derive, and the known minor issues that
were intentionally left for a follow-up session.

Contents:
- Project overview + current state (tag v0.1.1, branch main @ 310d507)
- Architecture diagram
- Critical code locations
- All 11 route endpoints in one table
- 5 key design decisions (SSE filter, no cell auto-inject, multi-session
  model, no partial-render markdown, real interactive permission/question
  UI) - written so the next session doesn't accidentally undo them
- How to run the tests
- Known minor issues / TODO list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 18:10:20 +08:00
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00
tao.chenandClaude 4ff5d7021c refactor: dedup setSessionId calls in session-switch path; add test coverage
Code review of 893f049 flagged 6 findings. This commit addresses them all:

#1 (real issue, production code): setSessionId was being called
TWICE per session switch - once explicitly from the cell-action
callback, and again from the prompt's _handleSwitchSession (which
also assigned _sessionId, called _resetStreamPointers, and set
_sessionSelect.value directly). The explicit setSessionId in the
cell-action callbacks (onCreateSession, onSwitchSession) is now
removed; the prompt's handlers do all of that bookkeeping through
a single setSessionId() call, which already implements the
dropdown + reset-stream-pointers logic.

#2 / #4 (test coverage): the existing switching test only checked
that the right HTTP call was made. New assertions: the prompt's
session-messages mock IS called again (history actually reloaded)
and the rendered history reflects the new session's content
(verifies the user sees the right messages, not stale DOM).

#3 (test coverage): new test for the error path. bind_existing
returns ok:false -> the prompt rolls the dropdown back to the
previous active id, leaves _sessionId unchanged, and shows a
Notification.error. This locks in the rollback behavior so a
future refactor can't silently break it.

#5 (test cleanup): mockedCallOpenCodeSessionMessages (the spy used
to verify history reload) is now declared at the top of the spec
file alongside the other mocked* variables, with a meaningful
name (mockedSessionMessages) instead of an inline jest.mocked()
that was hard to find.

#6 (source-doc): added a TODO(frontend) comment on
callOpenCodeSetActiveSession explaining why it is currently
unused (kept for a future 'switch between already-bound sessions'
flow that would avoid the bind round-trip) and warning a future
maintainer not to delete the corresponding server route without
removing the client function (or vice versa).

Tests: 77 jest (was 76), 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:00:29 +08:00
tao.chenandClaude 893f049eb7 fix: call bind (not set_active) when switching to a fresh session
Bug: 400 from PUT /opencode-bridge/sessions/active when the user picked
a session from the global list that wasn't yet bound to this notebook.
The cell-action onSwitchSession callback was:
  1. PUT /sessions/active (server checks bound -> 400)
  2. PUT /sessions/notebook (bind, but never reached)
The order was wrong: set_active requires the session to be bound FIRST.

Fix: the PUT /sessions/notebook route does both bind AND set active
in a single call (server handler: bind_existing() then response
includes the new activeSessionId). So the callback now only needs to
call bind-existing. The /sessions/active endpoint is still useful for
future "switch between already-bound sessions" flows but the current
"pick from global list" path doesn't need it.

Tests: updated the switching test to assert bind-existing is called
and set-active is NOT. Also added not.toHaveBeenCalled() to lock in
the new behavior. 76 jest, 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:39:27 +08:00
tao.chenandClaude d2bd4f1e48 feat: multi-session management (browse all + bind N sessions per notebook)
User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

Backend
-------
- SessionManager rewritten for 1-notebook-N-sessions: keeps both an
   map and a  map. New methods: create_new,
  bind_existing, set_active, unbind, unbind_active, delete_session,
  list_sessions_for_notebook. Existing get_or_create / peek /
  invalidate / list_sessions / release semantics preserved
  (release now deletes ALL bound sessions, not just the active).
- New route GET /opencode-bridge/sessions/all — list ALL OpenCode
  sessions via /session.
- New route GET /opencode-bridge/sessions/notebook?notebook=... —
  bound sessions for one notebook, enriched with title/createdAt
  from the global list.
- POST .../sessions/notebook — create a new session, bind, set
  active.
- PUT  .../sessions/notebook — bind an existing sessionId, set
  active (no OpenCode round-trip).
- DELETE .../sessions/notebook — delete the session on OpenCode
  AND remove its binding.
- New route GET/PUT/DELETE /opencode-bridge/sessions/active —
  read / switch / unbind the active session for one notebook.
- OpenCodeClient.list_all_sessions() — proxy OpenCode /session.

Frontend
-------
- SessionSelector dropdown in the inline prompt: lists ALL OpenCode
  sessions with the active one selected, plus a "+ 新建会话"
  sentinel entry. "+ 新建" button + "🔄" refresh button.
  Switching calls onSwitchSession; selecting "+ 新建会话" or
  clicking the new button calls onCreateSession. After both,
  the prompt's history is reloaded for the new active session.
- New cell-action callbacks: onListSessions, onCreateSession,
  onSwitchSession, onReloadHistory. Each closes the SSE first
  so events for the OLD session don't bleed in during the switch.
- 8 new API client functions in api/opencode_client.ts.
- New types in types.ts: OpenCodeSessionMeta,
  OpenCodeNotebookSession, OpenCodeAllSessionsResponse,
  OpenCodeNotebookSessionsResponse, OpenCodeSessionOpResponse.

Tests
-----
- 73 backend pytest (was 64): +6 new SessionManager multi-session
  unit tests + 7 new route tests (all-sessions, notebook GET/POST/
  PUT/DELETE, active GET/PUT/DELETE).
- 76 frontend jest (was 72): +4 new prompt tests (session selector
  rendered when callbacks wired, initialize() populates the list,
  + 新建 creates+binds, dropdown change switches and reloads).
- package.json version bumped 0.1.0 -> 0.1.1 to match the v0.1.1 tag
  that was already published.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:25:42 +08:00
19 changed files with 3060 additions and 1289 deletions
+207
View File
@@ -0,0 +1,207 @@
# 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` 上继续即可。
+10
View File
@@ -159,6 +159,16 @@ class OpenCodeClient:
"""
return await self._request("GET", "/session/%s/message" % session_id)
async def list_all_sessions(self) -> list[dict[str, Any]]:
"""List ALL sessions on the OpenCode Serve side (not just the ones
bound to a notebook in our SessionManager).
Returns the raw OpenCode response: a list of session objects with
metadata (``id``, ``title``, ``createdAt``, ``updatedAt``,
``messageCount`` etc., whatever the upstream returns).
"""
return await self._request("GET", "/session")
@property
def endpoint(self) -> str:
return self._config.url
+252 -37
View File
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
return sm
def _build_request_body(prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/prompt_async.
def _build_request_body(prompt: str) -> dict:
"""Build request body for OpenCode POST /session/:id/prompt_async.
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
the LLM interprets the user's natural-language instruction, with the code
context and the optional traceback in front of it.
v0.2.x: only the user's prompt is sent to the LLM. We no longer
auto-wrap the cell source / previous-cell / traceback in tags —
the user inserts whatever they want via the "📋 插入单元格内容"
button (or pastes manually), so the LLM context exactly matches
user intent. Returns dict with 'parts' and 'system' keys.
"""
parts: list[dict] = []
if context.get("previousCode"):
parts.append({
"type": "text",
"text": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
})
error = context.get("error")
if error:
parts.append({
"type": "text",
"text": (
"<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
+ "\n".join(error.get("traceback", []))
+ "\n</traceback>\n"
),
})
parts.append({
"type": "text",
"text": "<cell language='%s'>\n%s\n</cell>\n" % (
context.get("language", "python"),
context["source"],
),
})
if prompt:
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
return {
"parts": [{"type": "text", "text": prompt}],
"system": UNIFIED_SYSTEM_PROMPT,
}
def _strip_code_fence(s: str) -> str:
@@ -204,7 +179,7 @@ class EditHandler(APIHandler):
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
@@ -458,6 +433,242 @@ class SessionMessagesHandler(APIHandler):
self.finish(json.dumps({"messages": messages}))
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
#
# Model: each notebook can have multiple OpenCode sessions bound to it.
# One of the bound sessions is the "active" one (used by the next /edit).
# The user can:
# - list all OpenCode sessions (global, for the "browse" UI)
# - list sessions bound to a specific notebook
# - create a brand-new session and bind it (becomes active)
# - bind an existing session id (e.g. one they saw in the global list)
# - switch which bound session is active
# - unbind the active one (next /edit will lazy-create a new one)
# - delete a specific bound session (also removes it from OpenCode Serve)
# ---------------------------------------------------------------------------
class AllSessionsListHandler(APIHandler):
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
Serve, regardless of which notebook (if any) it is bound to. Powers
the "browse all conversations" UI in the cell-action panel.
"""
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
sessions = await client.list_all_sessions()
except OpenCodeError as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
class NotebookSessionsHandler(APIHandler):
"""Manage the list of sessions BOUND to one notebook.
GET /opencode-bridge/sessions/notebook?notebook=...
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
The metadata (title, createdAt, ...) is enriched from
OpenCode's `GET /session` so the frontend can render
the picker without a second round-trip per session.
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
-> { ok, sessionId, activeSessionId } — creates a new
session on OpenCode, binds it, sets as active.
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, sessionId, activeSessionId } — binds an
existing sessionId (e.g. one the user picked from
the global list) to this notebook and sets as active.
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, deleted } — deletes the session on OpenCode
Serve AND removes its binding from this notebook.
If it was the active one, clears active.
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
bound_ids = sm.list_sessions_for_notebook(notebook_path)
active = sm.get_active(notebook_path)
# Enrich with metadata from OpenCode Serve so the UI can show
# the title / createdAt without a second round-trip.
try:
all_sessions = await make_client(self).list_all_sessions()
by_id = {s.get("id"): s for s in (all_sessions or [])}
except Exception as e:
log.warning(
"could not enrich bound sessions with metadata: %s", e
)
by_id = {}
sessions = []
for sid in bound_ids:
meta = by_id.get(sid, {})
sessions.append({
"sessionId": sid,
"title": meta.get("title"),
"createdAt": meta.get("createdAt"),
"updatedAt": meta.get("updatedAt"),
"isActive": sid == active,
})
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": active,
"sessions": sessions,
}))
@tornado.web.authenticated
async def post(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
title = self.get_query_argument("title", None) or None
sm = get_session_manager(self)
try:
sid = await sm.create_new(notebook_path, title=title)
except OpenCodeError as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": sid,
"activeSessionId": sid,
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
sm.bind_existing(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
deleted = await sm.delete_session(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"deleted": deleted,
"activeSessionId": sm.get_active(notebook_path),
}))
class ActiveSessionHandler(APIHandler):
"""Manage the ACTIVE session for one notebook.
GET /opencode-bridge/sessions/active?notebook=...
-> { ok, activeSessionId, notebookPath }
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
-> { ok, activeSessionId } — switch the active bound
session. sessionId MUST already be in the bound list.
DELETE /opencode-bridge/sessions/active?notebook=...
-> { ok } — unbind the active session (next /edit will
lazy-create a new one). Does NOT delete the session
on OpenCode Serve (use the notebook DELETE for that).
"""
@tornado.web.authenticated
def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
if not sm.set_active(notebook_path, session_id):
self.set_status(400)
self.finish(json.dumps({
"ok": False,
"error": "sessionId is not bound to this notebook; "
"POST /sessions/notebook or PUT /sessions/notebook first"
}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sm.unbind_active(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
@@ -471,6 +682,10 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /permissions/<perm_id> and /questions/<q_id>/reply
# use a URLSpec with a regex so the path param is forwarded to
# the handler as a positional argument.
+211 -60
View File
@@ -1,13 +1,29 @@
"""Per-notebook session manager for OpenCode.
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
Async-safe via per-path asyncio.Lock. No automatic cleanup.
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
the bound sessions is the "active" one (the one used for the next
``/edit``). The others are historical — they were created in this
notebook at some point but the user switched away (e.g. started a
new conversation topic).
Lifecycle:
- ``get_or_create`` returns the active session, lazily creating one on
first use. This is the path used by ``EditHandler`` on every prompt.
- ``create_new`` always creates a brand-new session on OpenCode Serve,
binds it, sets it as active. Used by the "New session" UI action.
- ``bind_existing`` / ``set_active`` / ``unbind`` / ``delete_session``
are the management operations for the per-notebook session list.
Async-safety: a per-notebook ``asyncio.Lock`` serializes
``get_or_create`` so concurrent prompts on the same notebook don't
double-create. The lock survives across calls (held in ``_locks``) so
subsequent calls can reuse it.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Callable
from typing import Callable, Optional
from .opencode_client import OpenCodeClient
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
class SessionManager:
"""Tracks one OpenCode session per notebook path.
Threading/async model:
- Multiple coroutines may call get_or_create for the same notebook.
- First call creates; subsequent calls return the same sessionID.
- Per-notebook asyncio.Lock prevents double-create under concurrency.
- Locks remain in the map; they may be needed again for the same path.
"""
def __init__(self, client_factory: ClientFactory) -> None:
self._client_factory = client_factory
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
# notebookPath -> active sessionId
self._active: dict[str, str] = {}
# notebookPath -> ordered list of sessionIds bound to this
# notebook (the first is the oldest; the active one may be
# anywhere in the list). Used by the UI to render the session
# picker.
self._bound: dict[str, list[str]] = {}
# Per-notebook lock for serializing lazy-create.
self._locks: dict[str, asyncio.Lock] = {}
# ---------- active-session lookup / create ----------
async def get_or_create(self, notebook_path: str) -> str:
"""Return session ID for the notebook, creating one if needed.
Idempotent for the same path. Different paths get different sessions.
"""Return the active session id for this notebook, creating one
if no active session is bound yet. This is what ``EditHandler``
calls on every prompt.
"""
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
active = self._active.get(notebook_path)
if active is not None:
return active
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
async with lock:
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
client = self._client_factory()
session = await client.create_session(
title="jupyter:%s" % notebook_path
)
sid = session["id"]
self._sessions[notebook_path] = sid
self._titles[notebook_path] = notebook_path
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
active = self._active.get(notebook_path)
if active is not None:
return active
return await self._create_and_bind(notebook_path, title=None)
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if a session existed."""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
if sid is None:
async def _create_and_bind(
self, notebook_path: str, title: Optional[str]
) -> str:
client = self._client_factory()
session = await client.create_session(
title=title or "jupyter:%s" % notebook_path
)
sid = session["id"]
self._bind(notebook_path, sid)
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
def get_active(self, notebook_path: str) -> Optional[str]:
"""Return the active sessionId for the notebook, or None if no
active session has been bound/created yet. Does NOT create one.
"""
return self._active.get(notebook_path)
def peek(self, notebook_path: str) -> Optional[str]:
"""Alias for :meth:`get_active` (legacy name)."""
return self.get_active(notebook_path)
def has_session(self, notebook_path: str) -> bool:
"""True iff at least one session has ever been bound to this
notebook (active or not)."""
return notebook_path in self._bound
# ---------- mutation: bind / unbind / set_active / create_new ----------
def _bind(self, notebook_path: str, session_id: str) -> None:
"""Add session_id to the bound list (if not already) and set it
as active. Idempotent on re-bind.
"""
self._active[notebook_path] = session_id
bound = self._bound.setdefault(notebook_path, [])
if session_id not in bound:
bound.append(session_id)
async def create_new(
self, notebook_path: str, title: Optional[str] = None
) -> str:
"""Create a brand new session on OpenCode Serve, bind it to
this notebook, set as active, return its id.
"""
return await self._create_and_bind(notebook_path, title)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
"""Bind an existing sessionId to this notebook and set as active.
Does NOT call OpenCode — the caller must verify the session
exists (e.g. via ``GET /session``).
"""
self._bind(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
"""Switch the active session. Returns False if ``session_id`` is
not currently bound to this notebook (callers should bind first
if uncertain).
"""
bound = self._bound.get(notebook_path, [])
if session_id not in bound:
return False
self._active[notebook_path] = session_id
return True
def unbind(self, notebook_path: str, session_id: str) -> bool:
"""Remove ``session_id`` from this notebook's bindings. If it was
the active one, also clear active (next prompt will lazy-create
a new session). Returns True if it was bound.
"""
bound = self._bound.get(notebook_path)
if not bound or session_id not in bound:
return False
bound.remove(session_id)
if self._active.get(notebook_path) == session_id:
self._active.pop(notebook_path, None)
if not bound:
self._bound.pop(notebook_path, None)
return True
def unbind_active(self, notebook_path: str) -> bool:
"""Remove the active session binding only (leaves the rest of
the notebook's bound sessions intact). Next prompt will
lazy-create a new active session. Returns True if there was an
active binding to remove.
"""
active = self._active.pop(notebook_path, None)
if active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
bound.remove(active)
if not bound:
self._bound.pop(notebook_path, None)
return True
async def delete_session(
self, notebook_path: str, session_id: str
) -> bool:
"""Delete the session on OpenCode Serve AND remove its binding.
Order matters: we ask OpenCode to delete FIRST, and only remove
the local binding if OpenCode confirmed the delete. That way a
failed delete leaves the binding intact (the user can retry);
a deleted session never leaves a phantom binding behind.
Returns True if the session was actually deleted (i.e. it was
bound AND OpenCode acknowledged the delete). If the session is
not bound to this notebook, do NOT call OpenCode (we don't
want to silently delete sessions owned by other tools).
"""
if not self._is_bound(notebook_path, session_id):
return False
try:
client = self._client_factory()
return await client.delete_session(sid)
deleted = await client.delete_session(session_id)
except Exception:
log.warning(
"failed to delete opencode session %s for %s", sid, notebook_path
)
log.warning("failed to delete opencode session %s", session_id)
return False
# Whether the delete succeeded or just returned False (404), the
# session is gone on the OpenCode side — drop the binding to
# keep the UI consistent. We return whether OpenCode said
# "yes" so the caller can surface a warning on a 404.
self.unbind(notebook_path, session_id)
return deleted
def _is_bound(self, notebook_path: str, session_id: str) -> bool:
bound = self._bound.get(notebook_path)
return bool(bound) and session_id in bound
# ---------- legacy / compat ----------
async def release(self, notebook_path: str) -> bool:
"""Tear down ALL state for this notebook: delete every bound
session on OpenCode Serve AND clear every local binding.
Returns True iff at least one session was actually deleted
on the OpenCode side. (For deleting a SPECIFIC session use
:meth:`delete_session`.)
"""
bound = list(self._bound.get(notebook_path, []))
deleted_any = False
for sid in bound:
if await self.delete_session(notebook_path, sid):
deleted_any = True
# `delete_session` clears the binding. Belt-and-suspenders: drop
# any straggler state.
self._bound.pop(notebook_path, None)
self._active.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
return deleted_any
def invalidate(self, notebook_path: str) -> bool:
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
Use this when an upstream error indicates the session is dead (e.g., 404).
"""Drop the cached active sessionId without calling OpenCode.
Returns True if a binding was removed. Use this when an
upstream error indicates the session is dead (e.g. 404).
"""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
return sid is not None
def has_session(self, notebook_path: str) -> bool:
return notebook_path in self._sessions
def peek(self, notebook_path: str) -> Optional[str]:
"""Return the cached sessionID for the notebook, or None if no
session has been created yet. Does NOT create one (unlike
get_or_create) — used by the history endpoint to avoid spawning
a session just to report that there is none."""
return self._sessions.get(notebook_path)
was_active = self._active.pop(notebook_path, None)
if was_active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
try:
bound.remove(was_active)
except ValueError:
pass
if not bound:
self._bound.pop(notebook_path, None)
return True
def list_sessions(self) -> list[dict]:
"""Debug: list all (notebookPath, activeSessionId) pairs across
every notebook. One row per notebook — does NOT enumerate the
full bound-list per notebook. Use
:meth:`list_sessions_for_notebook` for that.
"""
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
for path, sid in sorted(self._active.items())
]
def list_sessions_for_notebook(self, notebook_path: str) -> list[str]:
"""All sessionIds bound to this notebook (in bind order: oldest
first, newest last). Empty list if none.
"""
return list(self._bound.get(notebook_path, []))
+16
View File
@@ -266,6 +266,22 @@ async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
assert body == {"answer": "use 3.12"}
@pytest.mark.asyncio
async def test_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None:
"""list_all_sessions proxies OpenCode Serve's GET /session and returns
the raw list (no projection)."""
payload = [
{"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2},
{"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4},
]
client, mock = _make_client(base_config, [(200, payload)])
sessions = await client.list_all_sessions()
assert sessions == payload
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session"
assert call["request"].method == "GET"
@pytest.mark.asyncio
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+339 -33
View File
@@ -57,6 +57,29 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
async def list_all_sessions(self):
self.calls.append(("list_all_sessions",))
return [
{
"id": "fake-session-123",
"title": "jupyter:foo.ipynb",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
},
{
"id": "other-session",
"title": "manual session",
"createdAt": 1700000010000,
"updatedAt": 1700000011000,
},
]
async def create_session(self, title):
self.calls.append(("create_session", title))
# Return a fresh id (different from the default "fake-session-123").
new_id = "newly-created-" + title
return {"id": new_id, "title": title}
async def reply_permission(self, session_id, permission_id, response):
self.calls.append(("reply_permission", session_id, permission_id, response))
return True
@@ -72,35 +95,84 @@ class FakeOpenCodeClient:
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
"""Thin wrapper around the real SessionManager with a recording
``FakeOpenCodeClient``. Lets route tests exercise the real
multi-session logic without touching OpenCode.
"""
def __init__(self, session_id: str = "fake-session-123") -> None:
self._session_id = session_id
self.calls: list = []
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
from opencode_bridge.session_manager import SessionManager
# `_client` attribute used by some legacy tests. Allow injection
# so a test can share its FakeOpenCodeClient with the SessionManager
# (the real SessionManager calls into its own client_factory, NOT
# the route's make_client).
self._client = client if client is not None else FakeOpenCodeClient()
self._sm = SessionManager(lambda: self._client)
self.calls: list = self._client.calls
# Legacy attribute used by list_sessions() etc.
self._sessions: dict = {}
async def get_or_create(self, notebook_path: str) -> str:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
# ---- delegation to the real SessionManager ----
def _record(self, name, *args):
self.calls.append((name, *args))
return None
def get_or_create(self, notebook_path: str):
self._record("get_or_create", notebook_path)
return self._sm.get_or_create(notebook_path)
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
return self._sm.peek(notebook_path)
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def get_active(self, notebook_path: str):
return self._sm.get_active(notebook_path)
def list_sessions(self) -> list:
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
]
return self._sm.list_sessions()
def list_sessions_for_notebook(self, notebook_path: str):
return self._sm.list_sessions_for_notebook(notebook_path)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
self._record("bind_existing", notebook_path, session_id)
self._sm.bind_existing(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
self._record("set_active", notebook_path, session_id)
return self._sm.set_active(notebook_path, session_id)
def unbind(self, notebook_path: str, session_id: str) -> bool:
self._record("unbind", notebook_path, session_id)
return self._sm.unbind(notebook_path, session_id)
def unbind_active(self, notebook_path: str) -> bool:
self._record("unbind_active", notebook_path)
return self._sm.unbind_active(notebook_path)
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
return True
self._record("invalidate", notebook_path)
return self._sm.invalidate(notebook_path)
async def release(self, notebook_path: str) -> bool:
self._record("release", notebook_path)
return await self._sm.release(notebook_path)
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
self._record("delete_session", notebook_path, session_id)
return await self._sm.delete_session(notebook_path, session_id)
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
self._record("create_new", notebook_path, title)
return await self._sm.create_new(notebook_path, title)
# ---- internal-state proxies (used by tests that poke internals) ----
@property
def _active(self) -> dict:
return self._sm._active
@property
def _bound(self) -> dict:
return self._sm._bound
async def test_hello(jp_fetch):
@@ -178,11 +250,11 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"ok": True,
"sessionId": "fake-session-123",
"notebookPath": "test.ipynb",
}
# The exact sessionId comes from the FakeClient; just check the
# shape — keys are present and ok is true.
assert payload["ok"] is True
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
assert payload["notebookPath"] == "test.ipynb"
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
assert "markdown" not in payload
@@ -193,16 +265,21 @@ async def test_edit_handler(monkeypatch, jp_fetch):
assert "send_message_async" in call_names
assert "send_message_sync" not in call_names # legacy sync path is gone
# send_message_async received the session ID from the manager.
# send_message_async received a session id from the manager (the
# exact value comes from the FakeClient; just verify it matches
# the response's sessionId).
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == "fake-session-123"
assert send_call[1] == payload["sessionId"]
# system prompt was passed.
assert "你是一个代码助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path.
# (The real SessionManager internally calls create_session on the
# FakeClient too, so the shared calls list has both entries.)
sm_call_names = [c[0] for c in fake_sm.calls]
assert sm_call_names == ["get_or_create"]
assert fake_sm.calls[0][1] == "test.ipynb"
assert "get_or_create" in sm_call_names
get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0]
assert get_or_create_call[1] == "test.ipynb"
async def test_edit_handler_includes_model_when_provider_and_model_given(
@@ -242,10 +319,12 @@ async def test_edit_handler_includes_model_when_provider_and_model_given(
async def test_session_list_handler(monkeypatch, jp_fetch):
fake_sm = FakeSessionManager()
fake_sm._sessions = {
"foo.ipynb": "sid-1",
"bar.ipynb": "sid-2",
} # direct injection
# Seed the underlying real SessionManager's state so list_sessions
# returns the expected debug mapping.
fake_sm._active["foo.ipynb"] = "sid-1"
fake_sm._active["bar.ipynb"] = "sid-2"
fake_sm._bound["foo.ipynb"] = ["sid-1"]
fake_sm._bound["bar.ipynb"] = ["sid-2"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -290,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages(
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
# Seed the bound state so peek() returns the session id (the route
# only fetches messages if there's an active session).
fake_sm._active["test.ipynb"] = "fake-session-123"
fake_sm._bound["test.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -332,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
# Set up state: get_or_create actually binds a session to the
# notebook in the real SessionManager (via the FakeClient). release
# then has something to delete.
await fake_sm.get_or_create("foo.ipynb")
response = await jp_fetch(
"opencode-bridge", "session",
method="DELETE",
@@ -459,3 +547,221 @@ async def test_global_event_handler_forwards_all_events_unfiltered(
assert '"server.connected"' in body
# The body should use the SSE format: each event as a `data:` line.
assert body.count("data: ") == 3
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "sessions", "all")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert len(payload["sessions"]) == 2
# The fake returns both the default session and a hand-rolled "other-session".
ids = {s["id"] for s in payload["sessions"]}
assert "fake-session-123" in ids
assert "other-session" in ids
assert ("list_all_sessions",) in fake.calls
async def test_notebook_sessions_get_returns_bound_with_metadata(
monkeypatch, jp_fetch
):
"""GET /sessions/notebook?notebook=... returns the bound sessions
enriched with title/createdAt from the global list."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm._active["foo.ipynb"] = "fake-session-123"
sm._bound["foo.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["notebookPath"] == "foo.ipynb"
assert payload["activeSessionId"] == "fake-session-123"
assert len(payload["sessions"]) == 1
s = payload["sessions"][0]
assert s["sessionId"] == "fake-session-123"
assert s["title"] == "jupyter:foo.ipynb"
assert s["isActive"] is True
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
"""POST /sessions/notebook creates a new session on OpenCode and
binds it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(client=fake),
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="POST",
params={"notebook": "foo.ipynb", "title": "fresh"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["sessionId"] == "newly-created-fresh"
# The new session is the active one.
assert payload["activeSessionId"] == "newly-created-fresh"
# OpenCode was called.
assert ("create_session", "fresh") in fake.calls
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
"""PUT /sessions/notebook binds an existing sessionId without
calling OpenCode. Sets it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["sessionId"] == "some-existing-sid"
assert payload["activeSessionId"] == "some-existing-sid"
# No OpenCode round-trip for the bind.
assert ("create_session", ...) not in [
c for c in fake.calls if c[0] == "create_session"
]
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
body=json.dumps({}),
)
assert exc_info.value.code == 400
async def test_active_session_set_switches(monkeypatch, jp_fetch):
"""PUT /sessions/active successfully switches when the sessionId is bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
"""DELETE /sessions/active unbinds the active session; other
bound sessions are kept; OpenCode is NOT called."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] is None
# sid-A is still bound; sid-B was the active and is gone.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in fake.calls)
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
the session on OpenCode AND removes its binding."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
sm = FakeSessionManager(client=fake)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="DELETE",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["deleted"] is True
# OpenCode delete was called for sid-A.
assert ("delete_session", "sid-A") in fake.calls
# sid-A is gone; sid-B remains active.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
assert sm.get_active("foo.ipynb") == "sid-B"
+165 -3
View File
@@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager
class FakeClient:
"""Mimics OpenCodeClient just enough for SessionManager."""
def __init__(self, session_id: str = "sid-from-fake") -> None:
"""Mimics OpenCodeClient just enough for SessionManager.
By default returns the same id for every create_session call
(matching the original legacy behavior; several pre-existing tests
rely on that). Tests that need a fresh id per call should set
``unique_ids_per_call=True`` in the constructor.
"""
def __init__(
self,
session_id: str = "sid-from-fake",
unique_ids_per_call: bool = False,
) -> None:
self._session_id = session_id
self._unique_ids = unique_ids_per_call
self._next_id = 0
self.calls: list = []
async def create_session(self, title: str) -> dict:
self.calls.append(("create_session", title))
if self._unique_ids:
self._next_id += 1
return {
"id": "%s-%d" % (self._session_id, self._next_id),
"title": title,
}
return {"id": self._session_id, "title": title}
async def delete_session(self, session_id: str) -> bool:
@@ -112,7 +130,7 @@ async def test_list_sessions_returns_all_mappings():
sm = SessionManager(lambda: client)
await sm.get_or_create("a.ipynb")
# Manually inject a second to test listing
sm._sessions["b.ipynb"] = "sid-b"
sm._active["b.ipynb"] = "sid-b"
listing = sm.list_sessions()
paths = {s["notebookPath"] for s in listing}
assert paths == {"a.ipynb", "b.ipynb"}
@@ -133,3 +151,147 @@ async def test_concurrent_get_or_create_does_not_double_create():
assert sids[0] == sids[1] == sids[2]
create_calls = [c for c in client.calls if c[0] == "create_session"]
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
# ---------------------------------------------------------------------------
# Multi-session: 1 notebook can bind N sessions, one is active
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_new_binds_and_makes_active():
client = FakeClient(unique_ids_per_call=True)
sm = SessionManager(lambda: client)
sid = await sm.create_new("foo.ipynb", title="custom")
assert sid == "sid-from-fake-1"
# New session is the active one and the only bound one.
assert sm.get_active("foo.ipynb") == sid
assert sm.list_sessions_for_notebook("foo.ipynb") == [sid]
@pytest.mark.asyncio
async def test_create_new_when_active_exists_keeps_old_bound():
client = FakeClient(unique_ids_per_call=True)
sm = SessionManager(lambda: client)
first = await sm.get_or_create("foo.ipynb")
second = await sm.create_new("foo.ipynb")
# Two distinct session ids.
assert first != second
assert first == "sid-from-fake-1"
assert second == "sid-from-fake-2"
# Both are bound; the new one is active.
bound = sm.list_sessions_for_notebook("foo.ipynb")
assert set(bound) == {first, second}
assert sm.get_active("foo.ipynb") == second
# get_or_create still returns the active one (does NOT auto-pick the latest).
assert await sm.get_or_create("foo.ipynb") == second
def test_bind_existing_does_not_call_opencode():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-from-list")
assert client.calls == [] # no OpenCode round-trip
assert sm.get_active("foo.ipynb") == "sid-from-list"
assert "sid-from-list" in sm.list_sessions_for_notebook("foo.ipynb")
def test_set_active_requires_session_to_be_bound():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
assert sm.set_active("foo.ipynb", "sid-A") is True
# Switching to a session that is NOT bound must fail.
assert sm.set_active("foo.ipynb", "sid-UNKNOWN") is False
# Active didn't change.
assert sm.get_active("foo.ipynb") == "sid-A"
def test_unbind_removes_from_bound_and_clears_active_if_was_active():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
# B is the active one (last bind wins). Unbind B.
assert sm.get_active("foo.ipynb") == "sid-B"
assert sm.unbind("foo.ipynb", "sid-B") is True
# Active falls back to None (no other session auto-promotes).
assert sm.get_active("foo.ipynb") is None
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
def test_unbind_non_active_leaves_active_intact():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
assert sm.get_active("foo.ipynb") == "sid-B"
# Unbind A (not the active one).
assert sm.unbind("foo.ipynb", "sid-A") is True
# Active is still B.
assert sm.get_active("foo.ipynb") == "sid-B"
def test_unbind_active_keeps_other_sessions():
sm = SessionManager(lambda: FakeClient())
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
sm.unbind_active("foo.ipynb")
assert sm.get_active("foo.ipynb") is None
# B is gone from the bound list; A remains.
assert "sid-A" in sm.list_sessions_for_notebook("foo.ipynb")
assert "sid-B" not in sm.list_sessions_for_notebook("foo.ipynb")
@pytest.mark.asyncio
async def test_delete_session_unbinds_and_calls_opencode():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
deleted = await sm.delete_session("foo.ipynb", "sid-A")
assert deleted is True
# OpenCode was called.
assert any(
c[0] == "delete_session" and c[1] == "sid-A" for c in client.calls
)
# sid-A is gone from bindings; sid-B remains as active.
assert "sid-A" not in sm.list_sessions_for_notebook("foo.ipynb")
assert sm.get_active("foo.ipynb") == "sid-B"
@pytest.mark.asyncio
async def test_delete_session_not_bound_returns_false():
client = FakeClient()
sm = SessionManager(lambda: client)
assert await sm.delete_session("foo.ipynb", "never-bound") is False
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in client.calls)
@pytest.mark.asyncio
async def test_release_drops_all_bindings_and_deletes_active():
client = FakeClient()
sm = SessionManager(lambda: client)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
ok = await sm.release("foo.ipynb")
assert ok is True
# OpenCode delete called for the active one (sid-B).
assert any(
c[0] == "delete_session" and c[1] == "sid-B" for c in client.calls
)
# Both bindings cleared.
assert sm.list_sessions_for_notebook("foo.ipynb") == []
assert sm.get_active("foo.ipynb") is None
@pytest.mark.asyncio
async def test_get_or_create_after_set_active_returns_the_active_one():
client = FakeClient()
sm = SessionManager(lambda: client)
first = await sm.get_or_create("foo.ipynb")
sm.bind_existing("foo.ipynb", "another-sid")
sm.set_active("foo.ipynb", "another-sid")
# Next /edit-style call returns the manually-set active session.
assert await sm.get_or_create("foo.ipynb") == "another-sid"
# No additional create_session call.
creates = [c for c in client.calls if c[0] == "create_session"]
assert len(creates) == 1 # only the initial get_or_create
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode_bridge",
"version": "0.1.0",
"version": "0.1.1",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
-154
View File
@@ -1,154 +0,0 @@
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
import type { NotebookPanel } from '@jupyterlab/notebook';
import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
return {
get length() {
return items.length;
},
get(i: number) {
return items[i];
},
// Other IObservableList methods are unused in collectErrorOutputs.
} as unknown as ICodeCellModel['outputs'];
}
function makeMockModel(overrides: {
id?: string;
type?: 'code' | 'markdown' | 'raw';
source?: string;
outputs?: unknown[];
}): ICodeCellModel {
return {
id: overrides.id ?? 'cell-1',
type: overrides.type ?? 'code',
sharedModel: {
getSource: () => overrides.source ?? '',
},
outputs: makeMockOutputs(overrides.outputs ?? []),
} as unknown as ICodeCellModel;
}
function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
}
function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
return { widgets: cells };
}
function makeMockPanel(
notebook: { widgets: CodeCell[] },
path: string
): NotebookPanel {
return {
context: { path } as NotebookPanel['context'],
content: notebook as unknown as NotebookPanel['content'],
} as unknown as NotebookPanel;
}
describe('collectErrorOutputs', () => {
it('returns null when there are no outputs', () => {
const model = makeMockModel({ outputs: [] });
expect(collectErrorOutputs(model)).toBeNull();
});
it('returns null when outputs are non-error', () => {
const model = makeMockModel({
outputs: [{ type: 'stream', text: 'hello' }],
});
expect(collectErrorOutputs(model)).toBeNull();
});
it('extracts the first error output', () => {
const errOut = {
type: 'error',
ename: 'ValueError',
evalue: 'bad value',
traceback: ['line 1', 'line 2'],
};
const model = makeMockModel({ outputs: [errOut] });
const result = collectErrorOutputs(model);
expect(result).toEqual({
ename: 'ValueError',
evalue: 'bad value',
traceback: ['line 1', 'line 2'],
});
});
it('handles missing traceback gracefully', () => {
const model = makeMockModel({
outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
});
const result = collectErrorOutputs(model);
expect(result?.traceback).toEqual([]);
});
});
describe('extractCellContext', () => {
it('returns null when cell is not in notebook', () => {
const cell = makeMockCell('x = 1');
const other = makeMockCell('y = 2');
const notebook = makeMockNotebook([other]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
expect(extractCellContext(cell, panel)).toBeNull();
});
it('extracts source, language, and indices', () => {
const cell = makeMockCell('x = 1');
const notebook = makeMockNotebook([cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx).toMatchObject({
notebookPath: 'foo.ipynb',
cellId: 'cell-1',
language: 'python',
cellIndex: 0,
totalCells: 1,
source: 'x = 1',
previousCode: null,
error: null,
});
});
it('captures the previous cell source as previousCode', () => {
const prev = makeMockCell('import os');
const cell = makeMockCell('os.getcwd()');
const notebook = makeMockNotebook([prev, cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx?.previousCode).toBe('import os');
expect(ctx?.cellIndex).toBe(1);
expect(ctx?.totalCells).toBe(2);
});
it('includes the error when the cell has an error output', () => {
const cell = makeMockCell('1/0', [
{
type: 'error',
ename: 'ZeroDivisionError',
evalue: 'division by zero',
traceback: ['Traceback...'],
},
]);
const notebook = makeMockNotebook([cell]);
const panel = makeMockPanel(notebook, 'foo.ipynb');
const ctx = extractCellContext(cell, panel);
expect(ctx?.error).toEqual({
ename: 'ZeroDivisionError',
evalue: 'division by zero',
traceback: ['Traceback...'],
});
});
it('returns null when given null cell or panel', () => {
expect(
extractCellContext(
null as unknown as CodeCell,
makeMockPanel(makeMockNotebook([]), 'x.ipynb')
)
).toBeNull();
});
});
+2 -9
View File
@@ -72,15 +72,8 @@ function mockResponse(overrides: {
const sampleRequest: OpenCodeRequest = {
prompt: 'add type hints',
context: {
notebookPath: 'foo.ipynb',
cellId: 'cell-1',
language: 'python',
cellIndex: 0,
totalCells: 1,
source: 'def foo(x): return x',
previousCode: null,
error: null,
},
notebookPath: 'foo.ipynb'
}
};
describe('callOpenCodeEdit', () => {
+228 -1
View File
@@ -11,11 +11,14 @@ import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type {
OpenCodeAllSessionsResponse,
OpenCodeEditResponse,
OpenCodeEvent,
OpenCodeMessagesResponse,
OpenCodeNotebookSessionsResponse,
OpenCodeProvidersResponse,
OpenCodeRequest
OpenCodeRequest,
OpenCodeSessionOpResponse
} from '../types';
/**
@@ -215,6 +218,230 @@ export async function callOpenCodeReplyQuestion(
return JSON.parse(text);
}
// ---------------------------------------------------------------------------
// Multi-session management (1 notebook can bind N sessions; one is active)
// ---------------------------------------------------------------------------
async function _jsonRequest<T>(
url: string,
init: RequestInit,
serverSettings: ServerConnection.ISettings,
label: string
): Promise<T> {
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
}
if (!text) {
throw new Error(`empty response from opencode-bridge/${label}`);
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error(
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
);
}
}
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
export async function callOpenCodeListAllSessions(
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeAllSessionsResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'all'
);
return _jsonRequest(
url,
{ method: 'GET' },
serverSettings,
'sessions/all'
);
}
/** GET /opencode-bridge/sessions/notebook?notebook=... sessions bound
* to this notebook (enriched with metadata, marked which is active). */
export async function callOpenCodeListNotebookSessions(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeNotebookSessionsResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
}
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=...
* create a brand-new session on OpenCode, bind it, set as active. */
export async function callOpenCodeCreateNotebookSession(
notebookPath: string,
title: string | undefined,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
(title ? '&title=' + encodeURIComponent(title) : '');
return _jsonRequest(
url,
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (POST)'
);
}
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* bind an existing sessionId to this notebook (no OpenCode round-trip)
* and set it as active. */
export async function callOpenCodeBindExistingSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* delete a specific bound session on OpenCode AND remove its binding. */
export async function callOpenCodeDeleteNotebookSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/notebook (DELETE)'
);
}
/** GET /opencode-bridge/sessions/active?notebook=... get the active
* sessionId (or null). */
export async function callOpenCodeGetActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
}
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
* switch which bound session is active. sessionId MUST already be
* in the bound list.
*
* TODO(frontend): unused in v0.1.x the cell-action's session
* switcher uses PUT /sessions/notebook (which does both bind and set
* active in one call). This is kept for a future "switch between
* already-bound sessions" flow that avoids the bind round-trip.
* Do NOT delete the corresponding server route without also
* removing this client function (or vice versa). */
export async function callOpenCodeSetActiveSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/active (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/active?notebook=... unbind the
* active session (next /edit lazy-creates a new one). Does NOT delete
* the session on OpenCode Serve. */
export async function callOpenCodeUnbindActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/active (DELETE)'
);
}
export interface OpenCodeEventHandlers {
onEvent: (event: OpenCodeEvent) => void;
onError?: (err: Error) => void;
-317
View File
@@ -1,317 +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 type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import {
callOpenCodeEdit,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
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;
private _context: CellContext | 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._context = extractCellContextFromCell(this._cell);
if (this._prompt) {
this._prompt.setDisabled(!this._context || this._status === 'loading');
} else {
this._render();
}
}
private _render(): void {
const node = this.node;
node.textContent = '';
const baseDisabled = !this._context || 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._context || this._status === 'loading',
providers: _providers,
notebookPath: this._context?.notebookPath,
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();
}
});
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._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
}
}
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._context) {
return;
}
if (!_serverSettings) {
Notification.error('OpenCode 运行时未初始化');
return;
}
const request = {
prompt: text,
context: this._context,
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);
}
}
/**
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
* then build the CellContext.
*/
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
let node: Widget | null = cell.parent;
let notebookPanel: NotebookPanel | null = null;
while (node) {
const candidate = node as any;
if (
candidate.context &&
candidate.content &&
Array.isArray(candidate.content.widgets)
) {
notebookPanel = candidate;
break;
}
node = node.parent;
}
if (!notebookPanel) {
return null;
}
return extractCellContext(cell, notebookPanel);
}
+381
View File
@@ -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);
}
}
+394 -52
View File
@@ -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';
@@ -28,12 +28,10 @@ import { marked } from 'marked';
import type {
OpenCodeEvent,
OpenCodeMessage,
OpenCodeProvidersResponse
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;
@@ -47,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
@@ -65,6 +73,19 @@ export interface IOpenCodeInlinePromptOptions {
* the prompt keeps the cell action from re-parsing event types.
*/
onStreamEnd?: () => void;
// ----- session management (multi-session UI) -----
/** List ALL OpenCode sessions (for the session-picker dropdown).
* Returns the raw array; the prompt picks the active one out. */
onListSessions?: () => Promise<OpenCodeSessionMeta[]>;
/** Create a brand-new session, bind it to the current notebook, and
* return its id (which becomes the new active session). */
onCreateSession?: () => Promise<string>;
/** Switch the active session. Returns true on success. */
onSwitchSession?: (sessionId: string) => Promise<boolean>;
/** Reload the message history for the currently-active session.
* Called after a create / switch so the panel reflects the new
* conversation's scrollback. */
onReloadHistory?: () => Promise<void>;
}
interface FlatProvider {
@@ -73,12 +94,16 @@ interface FlatProvider {
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') {
@@ -103,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];
@@ -115,11 +143,11 @@ interface BlockEntry {
}
export class OpenCodeInlinePrompt extends Widget {
private _cell: CodeCell;
private _options: IOpenCodeInlinePromptOptions;
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
private _cancelBtn: HTMLButtonElement;
private _insertCellBtn: HTMLButtonElement;
private _providerSelect: HTMLSelectElement | null = null;
private _modelSelect: HTMLSelectElement | null = null;
private _providerModels: FlatProvider[];
@@ -129,6 +157,10 @@ export class OpenCodeInlinePrompt extends Widget {
// Streaming state. _sessionId is set by the cell action after a successful
// /edit response; applyEvent() drops events that don't belong to it.
private _sessionId: string | null = null;
// Multi-session UI: the dropdown and its row (null when the host
// did not wire up the session callbacks).
private _sessionRow: HTMLDivElement | null = null;
private _sessionSelect: HTMLSelectElement | null = null;
// The current assistant message DOM node (re-rendered as markdown on
// every text delta so the user sees a properly formatted response in
// real-time, not raw ```fence``` source).
@@ -141,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');
@@ -206,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();
@@ -219,11 +247,86 @@ export class OpenCodeInlinePrompt extends Widget {
options.onCancel();
});
// "📋 插入单元格内容" button: appends the current cell's source
// (wrapped in a markdown code fence) to the textarea. Lets the
// user explicitly opt-in to including the cell content in the
// LLM prompt — the cell is no longer auto-injected.
this._insertCellBtn = document.createElement('button');
this._insertCellBtn.type = 'button';
this._insertCellBtn.className = 'opencode-btn-insert-cell';
this._insertCellBtn.textContent = '📋 插入单元格内容';
this._insertCellBtn.title =
'把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
this._insertCellBtn.addEventListener('click', () => {
this._insertCellSource();
});
const actions = document.createElement('div');
actions.className = 'opencode-inline-actions';
actions.appendChild(this._insertCellBtn);
actions.appendChild(this._sendBtn);
actions.appendChild(this._cancelBtn);
// Session selector (top of the panel, above provider/model).
// Only rendered when the host has provided the multi-session
// callbacks. The active session id is the option currently
// selected; switching the dropdown calls onSwitchSession, picking
// "+ 新建会话" calls onCreateSession, and the refresh button
// re-fetches the global session list.
if (
this._options.onListSessions &&
this._options.onCreateSession &&
this._options.onSwitchSession
) {
this._sessionRow = document.createElement('div');
this._sessionRow.className = 'opencode-prompt-sessions';
const sLabel = document.createElement('label');
const sSpan = document.createElement('span');
sSpan.textContent = 'Session:';
sLabel.appendChild(sSpan);
this._sessionSelect = document.createElement('select');
this._sessionSelect.className = 'opencode-session-select';
// Single placeholder until the list loads.
this._sessionSelect.appendChild(
this._mkSessionOption('', '加载中…', false)
);
this._sessionSelect.disabled = true;
this._sessionSelect.addEventListener('change', () => {
const v = this._sessionSelect!.value;
if (v === '__new__') {
void this._handleCreateSession();
} else if (v && v !== this._sessionId) {
void this._handleSwitchSession(v);
}
});
sLabel.appendChild(this._sessionSelect);
this._sessionRow.appendChild(sLabel);
const newBtn = document.createElement('button');
newBtn.type = 'button';
newBtn.className = 'opencode-session-btn-new';
newBtn.textContent = '+ 新建';
newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook';
newBtn.addEventListener('click', () => {
void this._handleCreateSession();
});
this._sessionRow.appendChild(newBtn);
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'opencode-session-btn-refresh';
refreshBtn.textContent = '🔄';
refreshBtn.title = '刷新 OpenCode 全局会话列表';
refreshBtn.addEventListener('click', () => {
void this._refreshSessionList();
});
this._sessionRow.appendChild(refreshBtn);
this.node.appendChild(this._sessionRow);
}
if (this._providerSelect || this._modelSelect) {
const row = document.createElement('div');
row.className = 'opencode-prompt-providers';
@@ -254,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(
@@ -330,6 +442,156 @@ export class OpenCodeInlinePrompt extends Widget {
return sel;
}
// ---------- session selector (multi-session UI) ----------
/**
* Repopulate the session dropdown with the given list. The currently
* active session (this._sessionId, set by setSessionId or by
* _handleSwitchSession) is marked selected.
*/
private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void {
if (!this._sessionSelect) {
return;
}
// Clear existing options (drop the loading placeholder).
while (this._sessionSelect.firstChild) {
this._sessionSelect.removeChild(this._sessionSelect.firstChild);
}
for (const s of sessions) {
const label = s.title
? `${s.title} (${s.id.slice(0, 8)}…)`
: `${s.id.slice(0, 8)}`;
this._sessionSelect.appendChild(
this._mkSessionOption(s.id, label, s.id === this._sessionId)
);
}
// Always-available "create new" sentinel.
this._sessionSelect.appendChild(
this._mkSessionOption('__new__', '+ 新建会话…', false)
);
// Reflect the current active id (or "" if none).
this._sessionSelect.value = this._sessionId || '';
if (!this._sessionId) {
// No active session; show a hint via the first option being empty.
this._sessionSelect.value = '';
}
this._sessionSelect.disabled = false;
}
private _mkSessionOption(
value: string,
label: string,
selected: boolean
): HTMLOptionElement {
const o = document.createElement('option');
o.value = value;
o.textContent = label;
if (selected) {
o.selected = true;
}
return o;
}
/**
* Fetch the global session list from the host and repopulate the
* dropdown. Called on prompt show, on "刷新" click, and after
* every create / switch. Errors are non-fatal the dropdown just
* keeps whatever it had (or stays empty).
*/
async _refreshSessionList(): Promise<void> {
if (!this._options.onListSessions) {
return;
}
try {
const sessions = await this._options.onListSessions();
this._populateSessionSelect(sessions);
} catch (e) {
console.warn('opencode_bridge: failed to list sessions', e);
}
}
/**
* "新建" button or dropdown's __new__ entry. Creates a session,
* binds it, sets as active, refreshes the dropdown, and reloads the
* message history for the new (empty) session.
*/
private async _handleCreateSession(): Promise<void> {
if (!this._options.onCreateSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const newId = await this._options.onCreateSession();
// setSessionId() handles _sessionId + dropdown + reset pointers
// in one call; avoids duplicate work in this handler.
this.setSessionId(newId);
this._historyEl.textContent = '';
// Re-fetch the global list so the new id is in the dropdown
// and marked active.
await this._refreshSessionList();
// Reload history for the new session (empty list, but this
// also re-syncs the prompt's view of "current state").
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} catch (e) {
Notification.error('创建会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* User picked a different session from the dropdown. Asks the host
* to bind + set active; on success, refreshes history.
*/
private async _handleSwitchSession(sessionId: string): Promise<void> {
if (!this._options.onSwitchSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const ok = await this._options.onSwitchSession(sessionId);
if (ok) {
// setSessionId() handles _sessionId + dropdown + reset
// pointers in one call; avoids duplicate work.
this.setSessionId(sessionId);
this._historyEl.textContent = '';
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} else {
// Roll back the select to the previous value.
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
Notification.error('切换会话失败');
}
} catch (e) {
Notification.error('切换会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
} finally {
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* Persist the current (providerSelect.value, modelSelect.value) for
* this notebook. No-op if the prompt has no notebookPath, no
@@ -356,6 +618,20 @@ export class OpenCodeInlinePrompt extends Widget {
if (this._modelSelect) {
this._modelSelect.disabled = disabled;
}
if (this._sessionSelect) {
// Don't disable the select while streaming — the user can still
// switch to a different session to inspect it. The cell action
// 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. */
@@ -363,18 +639,80 @@ export class OpenCodeInlinePrompt extends Widget {
this._textarea.value = '';
}
/**
* "📋 插入单元格内容" button: append the current cell's source as a
* 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 {
const cell = this._options.getActiveCell?.() ?? null;
if (!cell || !cell.model || !cell.model.sharedModel) {
Notification.info('请先选中一个 cell');
return;
}
const source = cell.model.sharedModel.getSource() as string;
if (!source || !source.trim()) {
Notification.info('当前 cell 为空');
return;
}
// 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 = (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;
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const insertion = (needsLeadingNewline ? '\n' : '') + block;
this._textarea.value = before + insertion;
// Move cursor to end so the user can continue typing.
this._textarea.focus();
const end = this._textarea.value.length;
this._textarea.setSelectionRange(end, end);
this._scrollToBottom();
}
/**
* Bind the prompt to a specific OpenCode session id. Events arriving via
* `applyEvent` for a different session are dropped. Pass null to clear.
*/
setSessionId(sessionId: string | null): void {
this._sessionId = sessionId;
if (this._sessionSelect) {
// Reflect the new active id in the dropdown without rebuilding it.
if (sessionId) {
const has = Array.from(this._sessionSelect.options).some(
o => o.value === sessionId
);
if (has) {
this._sessionSelect.value = sessionId;
}
} else {
this._sessionSelect.value = '';
}
}
if (sessionId) {
// Fresh turn: drop any in-progress stream DOM from a previous session.
this._resetStreamPointers();
}
}
/**
* First-time wiring after the prompt is constructed. Loads the global
* session list into the dropdown. Call from the host (cell action)
* right after constructing the widget.
*/
initialize(): void {
void this._refreshSessionList();
}
/**
* Render the current session's static messages (loaded once from
* /session-messages when the panel opens). Subsequent assistant turns
@@ -402,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 ||
@@ -413,16 +749,25 @@ export class OpenCodeInlinePrompt extends Widget {
event.payload.syncEvent &&
event.payload.syncEvent.aggregateID);
// Drop events for other sessions (defensive; matches demo.html
// handleGlobalEvent). Only drop when the event has an explicit
// sessionID — events without one (e.g. system events) are still
// processed and may be dropped by _isSystemEvent below.
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
return;
}
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
// Cross-session filtering: only strict for permission/question
// events (the user must NOT be able to reply to a permission
// prompt that belongs to a different session). Content events
// (text delta, reasoning, tool, idle) pass through regardless of
// sessionID match — the user is looking at the active session's
// panel, and we don't want to drop their reply because of a
// sessionID extraction edge case. The server already forwards
// ALL events; the SSE handler routes by URL ?session= param.
if (
(type === 'permission.asked' || type === 'question.asked') &&
sessionID &&
this._sessionId &&
sessionID !== this._sessionId
) {
return;
}
@@ -444,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;
@@ -502,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') {
@@ -564,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;
}
@@ -653,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 = '🔐 权限请求等待处理';
@@ -736,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 提问等待回复';
@@ -760,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();
@@ -795,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>';
@@ -925,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;
@@ -934,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));
@@ -959,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);
-81
View File
@@ -1,81 +0,0 @@
/**
* Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
*
* Pure functions, no DOM, no signals testable with plain object mocks.
*/
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
import type { NotebookPanel } from '@jupyterlab/notebook';
import type { CellContext, ErrorOutput } from '../types';
/**
* Find the first error output in a code cell's outputs.
* Returns null if the cell has no error output or is not a code cell.
*/
export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
const outputs = model.outputs;
for (let i = 0; i < outputs.length; i++) {
const out = outputs.get(i);
if (out && out.type === 'error') {
const e = out as {
ename?: unknown;
evalue?: unknown;
traceback?: unknown;
};
return {
ename: String(e.ename ?? ''),
evalue: String(e.evalue ?? ''),
traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
};
}
}
return null;
}
/**
* Build a CellContext from a CodeCell and its parent NotebookPanel.
* Returns null if essential data is missing.
*
* @param cell The code cell to inspect.
* @param panel The notebook panel that contains the cell.
*/
export function extractCellContext(
cell: CodeCell,
panel: NotebookPanel
): CellContext | null {
if (!cell || !panel) {
return null;
}
const cells = panel.content.widgets;
const cellIndex = cells ? cells.indexOf(cell) : -1;
if (cellIndex < 0) {
return null;
}
const totalCells = cells ? cells.length : 0;
const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
const previousCode = previousCell
? previousCell.model.sharedModel.getSource()
: null;
// Language: use 'python' as default for code cells.
// Future improvement: read from kernel info_reply.
const language = 'python';
let error: ErrorOutput | null = null;
if (cell.model.type === 'code') {
error = collectErrorOutputs(cell.model as ICodeCellModel);
}
return {
notebookPath: panel.context.path,
cellId: cell.model.id,
language,
cellIndex,
totalCells,
source: cell.model.sharedModel.getSource(),
previousCode,
error,
};
}
+23 -41
View File
@@ -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
);
+59 -13
View File
@@ -10,21 +10,19 @@
* a JSON object with a `type` string; consumers route on `type`.
*/
export interface ErrorOutput {
ename: string;
evalue: string;
traceback: string[];
}
/**
* Minimal context carried with each edit request. Only the
* notebookPath is needed (so the server can pick the right OpenCode
* session). The cell source, previous-cell, and traceback are NO
* LONGER auto-injected into the LLM prompt the user inserts them
* manually via the "📋 插入单元格内容" button (or pastes anything
* else they want) so the LLM only sees what they explicitly chose
* to send. v0.1.x used to pack a lot more here; that auto-wrap
* made the LLM context hard to reason about and made the user's
* intent ambiguous.
*/
export interface CellContext {
notebookPath: string;
cellId: string;
language: string;
cellIndex: number;
totalCells: number;
source: string;
previousCode: string | null;
error: ErrorOutput | null;
}
export interface OpenCodeRequest {
@@ -82,6 +80,54 @@ export interface OpenCodeMessagesResponse {
messages: OpenCodeMessage[];
}
// ---------------------------------------------------------------------------
// Multi-session management types (1 notebook can bind N sessions, one active)
// ---------------------------------------------------------------------------
/** One session on the OpenCode Serve side (returned by GET /session). */
export interface OpenCodeSessionMeta {
id: string;
title?: string;
createdAt?: number;
updatedAt?: number;
[k: string]: unknown;
}
/** Response from GET /opencode-bridge/sessions/all. */
export interface OpenCodeAllSessionsResponse {
ok: boolean;
sessions: OpenCodeSessionMeta[];
error?: string;
}
/** One session bound to a notebook (returned by GET /sessions/notebook). */
export interface OpenCodeNotebookSession {
sessionId: string;
title?: string;
createdAt?: number;
updatedAt?: number;
isActive: boolean;
}
/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */
export interface OpenCodeNotebookSessionsResponse {
ok: boolean;
notebookPath: string;
activeSessionId: string | null;
sessions: OpenCodeNotebookSession[];
error?: string;
}
/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */
export interface OpenCodeSessionOpResponse {
ok: boolean;
notebookPath: string;
sessionId?: string;
activeSessionId?: string | null;
deleted?: boolean;
error?: string;
}
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
* Each Provider.models is a Record keyed by modelID (NOT an array). */
export interface OpenCodeModel {
+120 -15
View File
@@ -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. */
@@ -191,6 +233,7 @@
display: flex;
justify-content: flex-end;
gap: 4px;
align-items: center;
}
.opencode-inline-prompt .opencode-inline-actions button {
@@ -211,6 +254,19 @@
cursor: default;
}
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
send/cancel so the user can tell at a glance it's a "modifier"
rather than a "submit/cancel" action. */
.opencode-inline-prompt .opencode-btn-insert-cell {
margin-right: auto; /* push to the left; send/cancel stay on the right */
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
background: #bfdbfe;
}
/* ------------------------------------------------------------------
Streaming event blocks (v4+ see opencode_inline_prompt.applyEvent)
------------------------------------------------------------------ */
@@ -339,3 +395,52 @@
.opencode-inline-prompt .opencode-q-submit:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
/* ------------------------------------------------------------------
Session selector (multi-session UI) top of the inline prompt
------------------------------------------------------------------ */
.opencode-inline-prompt .opencode-prompt-sessions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin: 2px 0 4px;
}
.opencode-inline-prompt .opencode-prompt-sessions label {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--jp-ui-font-size1);
color: var(--jp-ui-font-color1, #333);
flex: 1;
min-width: 0;
}
.opencode-inline-prompt .opencode-session-select {
flex: 1;
min-width: 0;
padding: 2px 4px;
font-size: var(--jp-ui-font-size1);
font-family: inherit;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
background: var(--jp-layout-color1, #fff);
}
.opencode-inline-prompt .opencode-session-btn-new,
.opencode-inline-prompt .opencode-session-btn-refresh {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color2, #f0f0f0);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #333);
}
.opencode-inline-prompt .opencode-session-btn-new:hover,
.opencode-inline-prompt .opencode-session-btn-refresh:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
.opencode-inline-prompt .opencode-session-btn-new {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}