2 Commits
Author SHA1 Message Date
tao.chenandClaude cae1eed1b1 feat: persist user (provider, model) selection per notebook in localStorage
CI / CI (pull_request) Failing after 3m15s
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:12:29 +08:00
tao.chenandClaude 4f8bbaf09f feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:00:16 +08:00
4 changed files with 37 additions and 74 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/*.whl dist/*.tar.gz; do
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
-2
View File
@@ -8,8 +8,6 @@ on:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
ci:
name: CI
+27 -46
View File
@@ -1005,17 +1005,13 @@ describe('OpenCodeInlinePrompt', () => {
warnSpy.mockRestore();
});
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
// Regression: a previous version ran marked.parse on every text
// delta. That produced glitchy partial-render states (e.g. an
// opening ```py fence with no closing fence yet is a different
// DOM shape than the complete code block, so the toolbar
// post-processing flickered in and out between deltas). The fix
// is to keep the streaming path dumb (append raw text only) and
// run the markdown render exactly once at the end of the turn
// (session.idle). The user sees real-time text grow during the
// stream, and a clean rendered view the moment the turn ends —
// matching what setMessages would produce from the stored history.
it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
// Regression: previously the streaming path appended deltas to
// .textContent, so the user saw raw ```py\n...\n``` source while
// the model was still typing. Only after closing+reopening the
// panel (which goes through setMessages + marked.parse) did the
// code block render as <pre><code>. The fix: re-render via
// marked.parse on every delta.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -1024,58 +1020,43 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
prompt.setSessionId('s1');
const fullSource =
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
// Stream every delta of the source.
for (const d of fullSource.split(/(?=H|```|\n)/)) {
if (!d) continue;
// Simulate a streaming reply that contains a fenced code block.
const deltas = [
'Here is the fix:\n\n',
'```python\n',
'import pandas as pd\n',
'import numpy as np\n',
'```\n'
];
for (const d of deltas) {
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
// During streaming: raw text, NO markdown render yet.
const asstDuring = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
expect(asstDuring.textContent).toBe(fullSource);
// The ```fence``` characters are present as literal text — the
// user is looking at the source, not the rendered view.
expect(asstDuring.textContent).toContain('```python');
// No <pre> element yet (no markdown render was attempted).
expect(asstDuring.querySelector('pre')).toBeNull();
// No toolbar yet either.
expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
// Still a single assistant element (no forks per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-msg-assistant'
).length
).toBe(1);
// Turn end -> session.idle triggers the final markdown render
// (via _resetStreamPointers, which the prompt invokes before
// calling onStreamEnd).
prompt.applyEvent({
type: 'session.idle',
properties: { sessionID: 's1' }
});
// After idle: the assistant message is now a fully rendered
// markdown DOM, identical to what setMessages would produce.
// The <pre><code> block is rendered DURING streaming, not just at
// the end. The raw ```fence``` characters must NOT appear as visible
// text content in the message body.
const pre = prompt.node.querySelector(
'.opencode-msg-assistant .opencode-msg-content pre'
) as HTMLElement;
expect(pre).not.toBeNull();
const code = pre.querySelector('code') as HTMLElement;
expect(code).not.toBeNull();
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
// The Copy / Insert / Replace toolbar is attached to the <pre>
// block on the final render, same as in setMessages.
// block during streaming, same as in setMessages.
const toolbar = pre.parentElement!.querySelector(
'.opencode-code-toolbar'
) as HTMLElement;
expect(toolbar).not.toBeNull();
expect(toolbar.querySelectorAll('button').length).toBe(3);
// The wrapper still has a single assistant element (not one per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-inline-prompt .opencode-msg-assistant'
).length
).toBe(1);
});
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
+9 -25
View File
@@ -596,19 +596,16 @@ export class OpenCodeInlinePrompt extends Widget {
if (!this._currentAssistantEl) {
this._currentAssistantEl = this._createMessageElement('assistant', '');
}
// During streaming, append the raw delta to the assistant element's
// textContent. We do NOT run marked.parse on every delta: the
// partial source (e.g. an opening ```py fence with no closing
// fence yet) is structurally different from the complete message
// and the toolbar post-processing creates visible flicker —
// <pre>s appear and disappear as the renderer re-classifies the
// incomplete markdown between deltas. The user gets stable
// real-time text here, and the full markdown render happens once
// at the end of the turn (see _resetStreamPointers) so the result
// is identical to what setMessages would produce from the stored
// history.
// Accumulate the raw markdown source, then re-render the whole
// message through marked.parse. This way the user sees properly
// formatted output (code blocks as <pre><code>, headings, lists)
// in real-time as text deltas arrive — NOT the raw ```fence```
// source that an append-only .textContent would show.
this._currentAssistantText += text;
this._currentAssistantEl.textContent = this._currentAssistantText;
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
this._scrollToBottom();
}
@@ -810,19 +807,6 @@ export class OpenCodeInlinePrompt extends Widget {
}
private _resetStreamPointers(): void {
// Finalize the in-progress assistant message by running the
// markdown render ONCE on the accumulated raw text. This is the
// moment where the streaming path converges to the same DOM
// structure that setMessages would produce from the stored
// history (i.e. identical to "reopen" behavior). Without this
// final pass the user would see raw ```fence``` source
// permanently until they close+reopen the panel.
if (this._currentAssistantEl && this._currentAssistantText) {
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
}
this._currentAssistantEl = null;
this._currentAssistantText = '';
this._activeBlocks = {};