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:
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -33,11 +32,7 @@ import {
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
OpenCodeProvidersResponse
|
||||
} from '../types';
|
||||
import type { OpenCodeProvidersResponse } from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
|
||||
@@ -61,7 +56,11 @@ type Status = 'idle' | 'loading';
|
||||
|
||||
export class OpenCodeCellActions extends Widget {
|
||||
private _cell: CodeCell;
|
||||
private _context: CellContext | null = null;
|
||||
// The cell's notebookPath is the only context we need for the
|
||||
// /edit request. The cell source, error traceback, and previous
|
||||
// cell are no longer auto-injected — the user attaches whatever
|
||||
// they want via the "📋 插入单元格内容" button.
|
||||
private _notebookPath: string | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||
@@ -88,9 +87,9 @@ export class OpenCodeCellActions extends Widget {
|
||||
}
|
||||
|
||||
private _onModelChange(): void {
|
||||
this._context = extractCellContextFromCell(this._cell);
|
||||
this._notebookPath = extractNotebookPathFromCell(this._cell);
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(!this._context || this._status === 'loading');
|
||||
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
|
||||
} else {
|
||||
this._render();
|
||||
}
|
||||
@@ -100,7 +99,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
const node = this.node;
|
||||
node.textContent = '';
|
||||
|
||||
const baseDisabled = !this._context || this._status === 'loading';
|
||||
const baseDisabled = !this._notebookPath || this._status === 'loading';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'opencode-btn opencode-btn-ai';
|
||||
@@ -128,9 +127,9 @@ export class OpenCodeCellActions extends Widget {
|
||||
return;
|
||||
}
|
||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||
disabled: !this._context || this._status === 'loading',
|
||||
disabled: !this._notebookPath || this._status === 'loading',
|
||||
providers: _providers,
|
||||
notebookPath: this._context?.notebookPath,
|
||||
notebookPath: this._notebookPath ?? undefined,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
@@ -196,14 +195,14 @@ export class OpenCodeCellActions extends Widget {
|
||||
}
|
||||
},
|
||||
onCreateSession: async () => {
|
||||
if (!_serverSettings || !this._context?.notebookPath) {
|
||||
if (!_serverSettings || !this._notebookPath) {
|
||||
throw new Error('notebook not ready');
|
||||
}
|
||||
// Tear down any in-flight SSE first — the old session is
|
||||
// being replaced.
|
||||
this._closeSse();
|
||||
const r = await callOpenCodeCreateNotebookSession(
|
||||
this._context.notebookPath,
|
||||
this._notebookPath,
|
||||
undefined,
|
||||
_serverSettings
|
||||
);
|
||||
@@ -215,7 +214,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
return r.sessionId;
|
||||
},
|
||||
onSwitchSession: async (sessionId: string) => {
|
||||
if (!_serverSettings || !this._context?.notebookPath) {
|
||||
if (!_serverSettings || !this._notebookPath) {
|
||||
return false;
|
||||
}
|
||||
// Closing the SSE first ensures the old session's events stop
|
||||
@@ -227,7 +226,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
// user just picked from the global list, we need to bind
|
||||
// first — calling set_active before bind returns 400.)
|
||||
const r = await callOpenCodeBindExistingSession(
|
||||
this._context.notebookPath,
|
||||
this._notebookPath,
|
||||
sessionId,
|
||||
_serverSettings
|
||||
);
|
||||
@@ -239,8 +238,8 @@ export class OpenCodeCellActions extends Widget {
|
||||
return true;
|
||||
},
|
||||
onReloadHistory: async () => {
|
||||
if (!this._context?.notebookPath) return;
|
||||
await this._refreshHistory(this._context.notebookPath);
|
||||
if (!this._notebookPath) return;
|
||||
await this._refreshHistory(this._notebookPath);
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
@@ -248,7 +247,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
|
||||
// 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;
|
||||
const notebookPath = this._notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
@@ -286,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
providerId?: string,
|
||||
modelId?: string
|
||||
): Promise<void> {
|
||||
if (!this._context) {
|
||||
if (!this._notebookPath) {
|
||||
return;
|
||||
}
|
||||
if (!_serverSettings) {
|
||||
@@ -296,7 +295,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
|
||||
const request = {
|
||||
prompt: text,
|
||||
context: this._context,
|
||||
context: { notebookPath: this._notebookPath },
|
||||
providerId,
|
||||
modelId
|
||||
};
|
||||
@@ -357,26 +356,23 @@ export class OpenCodeCellActions extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
||||
* then build the CellContext.
|
||||
* Walk the parent chain to find the cell's parent NotebookPanel and
|
||||
* return its content path. Returns null if the cell is detached
|
||||
* (no notebook parent).
|
||||
*/
|
||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
||||
function extractNotebookPathFromCell(cell: CodeCell): string | null {
|
||||
let node: Widget | null = cell.parent;
|
||||
let notebookPanel: NotebookPanel | null = null;
|
||||
while (node) {
|
||||
const candidate = node as any;
|
||||
if (
|
||||
candidate.context &&
|
||||
typeof candidate.context.path === 'string' &&
|
||||
candidate.content &&
|
||||
Array.isArray(candidate.content.widgets)
|
||||
) {
|
||||
notebookPanel = candidate;
|
||||
break;
|
||||
return candidate.context.path;
|
||||
}
|
||||
node = node.parent;
|
||||
}
|
||||
if (!notebookPanel) {
|
||||
return null;
|
||||
}
|
||||
return extractCellContext(cell, notebookPanel);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user