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.
This commit is contained in:
tao.chen
2026-07-28 18:02:21 +08:00
parent 4ff5d7021c
commit 310d50759a
9 changed files with 238 additions and 340 deletions
+67 -7
View File
@@ -135,6 +135,7 @@ export class OpenCodeInlinePrompt extends Widget {
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[];
@@ -238,8 +239,22 @@ 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);
@@ -598,6 +613,42 @@ 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).
*/
private _insertCellSource(): void {
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
Notification.info('当前 cell 没有内容');
return;
}
const source = this._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 =
(this._cell.model as any).sharedModel && (this._cell.model as any).type
? ((this._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.
@@ -670,16 +721,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;
}